qualia2fa_ffi/lib.rs
1// Copyright (C) 2026 Qualia2FA contributors
2//
3// This library is free software; you can redistribute it and/or modify it under
4// the terms of the GNU Lesser General Public License as published by the Free
5// Software Foundation; either version 3 of the License, or (at your option) any
6// later version.
7//
8// This library is distributed in the hope that it will be useful, but WITHOUT
9// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10// FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
11// details.
12//
13// You should have received a copy of the GNU Lesser General Public License
14// along with this library; if not, see <https://www.gnu.org/licenses/>.
15
16//! FFI layer of Qualia2FA: exposes `qualia2fa-core` as UniFFI bindings.
17//!
18//! # Responsibility of this crate
19//!
20//! This crate **only moves data**: it converts the primitive types passed in from
21//! Kotlin/Swift into core types, calls the core, and converts the results back.
22//! **Any cryptography, parsing or validation logic in this file is a design
23//! regression** — such logic must stay inside `qualia2fa-core`, the only layer
24//! that is free of unsafe code and can be audited on its own.
25//!
26//! A review of this crate has a single checklist item: **has any core logic been
27//! moved in here?**
28//!
29//! `uniffi::setup_scaffolding!()` expands the unsafe code the FFI boundary needs
30//! inside this crate (raw pointer dereferences, `catch_unwind`, transfer of string
31//! ownership), so this crate is not `forbid(unsafe_code)`. The safety boundary runs
32//! **between crates**: the core is `forbid(unsafe_code)`, and this crate is the
33//! only place allowed to contain unsafe code, which must stay thin. `Cargo.toml`
34//! sets `unsafe_code` to `warn` rather than `allow`, so new unsafe code is reported
35//! at build time.
36//!
37//! # Handling of secret material
38//!
39//! The core's [`OtpSecret`] **deliberately implements neither `Clone` nor a raw
40//! byte accessor**, and its `Debug` does not even print the length. Across the FFI
41//! boundary a seed is always represented as a **Base32 string**:
42//!
43//! - inbound: `&str` -> [`OtpSecret::from_base32`]
44//! - outbound: [`OtpSecret::to_base32`]
45//!
46//! Seed bytes therefore never cross the FFI boundary as a `Vec<u8>`, which would
47//! leave one copy on each side outside the control of any erasure. Base32 is the
48//! single narrow exit already chosen by the core, and this layer reuses it.
49//!
50//! **Known residual risk**: on the Kotlin/Swift side a Base32 string is an
51//! ordinary `String`, not protected by `secrecy`'s erasure, and it may be copied
52//! by the garbage collector or stored in a string pool. That is an inherent
53//! limitation of managed languages, so **the client side must adopt its own
54//! discipline**: a seed's Base32 exists only in the scope that needs it, is
55//! dropped immediately afterwards, and is never logged or persisted.
56
57uniffi::setup_scaffolding!();
58
59use std::collections::HashMap;
60use std::net::SocketAddr;
61use std::sync::{Arc, Mutex};
62
63use qualia_bioauth as bioauth;
64use qualia_bioauth::Backend;
65use qualia_dik::keystore::PlatformKeyStore;
66use qualia_dik::keystore::platform::{PlatformDirectMasterKeyStore, PlatformWrappedMasterKeyStore};
67use qualia_dik::{
68 DEVICE_ID_LEN, DeviceId, DeviceKeyStore, DevicePublicKey, DeviceSignature, DeviceSigner,
69 DirectMasterKeyStore, MASTER_KEY_LEN, MasterKey as DikMasterKey, WrappedMasterKey,
70 WrappedMasterKeyStore,
71};
72use qualia_transport::lan::transport::Recipient as LanRecipient;
73use qualia_transport::lan::transport::{IrohLan, MAX_MESSAGE_LEN, PeerAddress};
74use qualia_transport::lan::wire::{ENDPOINT_KEY_LEN, EndpointKey, Peer, PeerStatus};
75use qualia_transport::lan::{LanAddress, lobby::Lobby};
76use qualia2fa_core::auth;
77use qualia2fa_core::flow::{self, DeviceKeys as _, Joiner, Login, SecureStore as _};
78use qualia2fa_core::import::apply::{Choice as ImportChoice, Difference as ImportDifference};
79use qualia2fa_core::motp::{self, MotpPin};
80use qualia2fa_core::otp::{self, Algorithm, Digits, OtpSecret};
81use qualia2fa_core::recovery::KdfParams;
82use qualia2fa_core::sync::{
83 Conflict, ConflictKind, Decision, PayloadKind, SeenSyncIds, adjudicate,
84};
85use qualia2fa_core::transport::{
86 Recipient, Roster, SharePlan, SyncSession, Transport, TransportCapabilities,
87};
88use qualia2fa_core::uri;
89use qualia2fa_core::vault::{Entry, EntryKind, Group, GroupChild, MasterKey, Vault, VaultFile};
90use qualia2fa_core::yaotp::{self, YandexPin};
91use qualia2fa_core::{Error, ExposeSecret as _, SecretString};
92
93/// Length of a challenge-response session identifier.
94///
95/// The core's `auth::SESSION_ID_LEN` is a **private** constant (it belongs to the
96/// protocol internals and is not meant to be depended on from above), so the
97/// value is written out here. It is not arbitrary: the core's
98/// `pairing::SESSION_ID_LEN` is 32 as well, and the two are equal on purpose —
99/// session identifiers on one channel must have the same length, otherwise a
100/// length comparison would report a difference where there is none.
101const AUTH_SESSION_ID_LEN: usize = 32;
102
103/// Length of a challenge-response confirmation tag (core `auth::CONFIRM_LEN`, also private).
104const AUTH_CONFIRM_LEN: usize = 32;
105
106/// Maximum length in characters of a seed string crossing the FFI boundary.
107///
108/// Real seeds are 16-64 bytes (about 26-104 Base32 characters). The limit is 2048,
109/// which leaves room for non-standard cases while rejecting meaningless input such
110/// as a huge string a client was tricked into passing in.
111///
112/// **This is a defensive limit, not a specification requirement**: the core
113/// deliberately sets no lower bound on seed length (short seeds exist in the
114/// wild), and judging whether a length is adequate is a UI-layer warning, see
115/// [`is_secret_shorter_than_recommended`].
116const MAX_SECRET_CHARS: usize = 2048;
117
118/// Maximum length in characters of an `otpauth://` URI crossing the FFI boundary.
119///
120/// Real URIs are usually shorter than 200 characters. A QR code holds at most
121/// about 2953 bytes, so 4096 covers any URI that can arrive from a QR code while
122/// rejecting oversized input.
123const MAX_URI_CHARS: usize = 4096;
124
125/// Maximum length in characters of a vault field crossing the FFI boundary.
126///
127/// The core enforces its own limit on decrypted fields (`Entry::MAX_FIELD_LEN`,
128/// 64 KiB bytes), but that is the **post-decryption** defence. This constant
129/// guards the side coming in from the client: a UI text box has no reason to hold
130/// more than this, and rejecting at the boundary is cheaper than running a whole
131/// encryption pass only to fail inside the core.
132///
133/// The limit counts characters (8 Ki characters) rather than the core's 64 KiB
134/// bytes because what the client sends is a string; counting bytes would make a
135/// note containing non-ASCII text hit the limit much earlier. 8 Ki characters is
136/// far beyond any real service name, account name or note.
137const MAX_VAULT_FIELD_CHARS: usize = 8 * 1024;
138
139/// Maximum length in characters of a device label crossing the FFI boundary.
140///
141/// The label ends up in the platform key store, and platforms differ in how long a
142/// key name may be (Windows CNG, Android Keystore and the iOS keychain all impose
143/// limits, and not all of them are documented). Real labels are short strings such
144/// as `qualia2fa-device`, so 256 characters is ample while still rejecting an
145/// oversized label passed in by a client.
146const MAX_LABEL_CHARS: usize = 256;
147
148/// Maximum length in characters of a passphrase crossing the FFI boundary.
149///
150/// The passphrase is fed to Argon2, whose running time grows linearly with the
151/// input length. Without a limit, a multi-megabyte "passphrase" could occupy the
152/// thread for a long time during export or import. **The limit is not a security
153/// measure** (passphrase strength comes from entropy, not length); it only
154/// rejects meaningless input.
155const MAX_PASSPHRASE_CHARS: usize = 1024;
156
157/// Maximum length in characters of QR code text crossing the FFI boundary.
158///
159/// The QR payload is 99 bytes, 159 characters in Base32. The limit is 512, which
160/// leaves room for text the client may carry around the payload (for example a
161/// user copying the code together with quotes from a chat log) while rejecting
162/// oversized input.
163const MAX_QR_TEXT_CHARS: usize = 512;
164
165/// Summary of a core error, carried across the FFI boundary.
166///
167/// The core's [`Error`] is a `#[non_exhaustive]` rich error enum (carrying
168/// `String`, `usize` and other payloads) and is not a UniFFI type: UniFFI requires
169/// every field of an error enum to implement `Lift`/`Lower`/`TypeId`, and with
170/// `#[uniffi(flat_error)]` the variants would cross the boundary as `Display` text
171/// only, leaving the client one opaque string and **no way to dispatch the UI by
172/// failure reason**. This type carries two fields:
173///
174/// - `kind`: a stable machine-readable identifier the client branches on
175/// - `message`: user-facing text, for display and logs only
176#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
177pub struct FfiCoreError {
178 /// Stable error identifier; see the contract in [`core_error_kind`].
179 pub kind: String,
180 /// Human-readable error text (identical to the core's `Display`).
181 pub message: String,
182}
183
184/// Maps a core error to a **stable** machine-readable identifier.
185///
186/// # Stability contract
187///
188/// These strings are what the client `switch`es on, so they are **part of the
189/// public interface**: new values may be added, but existing values must not be
190/// renamed or change meaning (a rename breaks client behaviour).
191///
192/// The client must have a fallback branch for unknown `kind` values: the core is
193/// `#[non_exhaustive]`, so variants will be added and older clients will meet
194/// values they have never seen.
195///
196/// `format!("{source:?}")` and automatic derivation from the variant name are
197/// deliberately not used: `Debug` output is an implementation detail, renaming a
198/// variant would silently change client behaviour, and it may contain payload
199/// contents. Every string is written out in an explicit `match` arm so that a
200/// rename is visible during review.
201///
202/// # Notes on individual kinds
203///
204/// - `vault_entry_not_found` is distinct from an empty vault: the entry the caller
205/// is acting on no longer exists.
206/// - `vault_bad_key_check` reports a structural problem (the key check value has
207/// the wrong length, or the file is truncated or malformed), whereas
208/// `vault_decryption_failed` reports a wrong key or a file that was modified.
209/// - `device_not_registered` means this device has never been registered;
210/// `vault_decryption_failed` means it was registered but the data cannot be
211/// decrypted.
212/// - `pairing_authentication_failed` deliberately does not distinguish a modified
213/// ciphertext, a wrong session and a wrong joiner public key, so that an attacker
214/// gets no oracle for "almost succeeded".
215/// - Failures reported by the device identity library are split into
216/// `device_public_key_invalid`, `device_signature_invalid` and `device_error`
217/// rather than one generic kind. The master key storage failures carry kinds of
218/// their own — `invalid_wrapped_key`, `invalid_master_key`, `key_not_exportable`,
219/// `hardware_unavailable`, `unsupported_platform` — because each one sends the
220/// host side down a different path.
221/// - The final `_ => "unknown"` arm is **required**: the core's `Error` is
222/// `#[non_exhaustive]`, so a wildcard arm is mandatory outside its own crate
223/// (removing it is `E0004`). The price is that the compiler does not flag a newly
224/// added core variant — `"unknown"` silently absorbs it and the client receives a
225/// kind it cannot dispatch. The `every_mapped_error_kind_is_distinct_and_known`
226/// test guards against that instead of the compiler: add one mapping arm per new
227/// variant and add the value to that test's list.
228#[must_use]
229pub fn core_error_kind(source: &Error) -> &'static str {
230 match source {
231 Error::Base32InvalidCharacter { .. } => "base32_invalid_character",
232 Error::Base32InvalidLength { .. } => "base32_invalid_length",
233 Error::Base32InvalidPadding => "base32_invalid_padding",
234 Error::SecretEmpty => "secret_empty",
235 Error::SecretTooShort { .. } => "secret_too_short",
236 Error::UnsupportedAlgorithm { .. } => "unsupported_algorithm",
237 Error::UnsupportedDigits { .. } => "unsupported_digits",
238 Error::InvalidPeriod => "invalid_period",
239 Error::RandomnessUnavailable => "randomness_unavailable",
240 Error::UriUnsupportedScheme => "uri_unsupported_scheme",
241 Error::UriMissingType => "uri_missing_type",
242 Error::UriUnsupportedType { .. } => "uri_unsupported_type",
243 Error::UriMissingSecret => "uri_missing_secret",
244 Error::UriMissingCounter => "uri_missing_counter",
245 Error::UriUnexpectedCounter => "uri_unexpected_counter",
246 Error::UriDuplicateParameter { .. } => "uri_duplicate_parameter",
247 Error::UriInvalidParameter { .. } => "uri_invalid_parameter",
248 Error::UriInvalidPercentEncoding => "uri_invalid_percent_encoding",
249 Error::UriIssuerMismatch { .. } => "uri_issuer_mismatch",
250 Error::UriAccountContainsColon { .. } => "uri_account_contains_colon",
251 // ── Vault ──────────────────────────────────────────────────────────
252 Error::VaultTruncated => "vault_truncated",
253 Error::VaultBadMagic => "vault_bad_magic",
254 Error::VaultUnsupportedVersion { .. } => "vault_unsupported_version",
255 Error::VaultReservedByteNotZero { .. } => "vault_reserved_byte_not_zero",
256 Error::VaultUnknownRecordKind { .. } => "vault_unknown_record_kind",
257 Error::VaultRecordTooLong { .. } => "vault_record_too_long",
258 Error::VaultTooManyRecords => "vault_too_many_records",
259 Error::VaultTrailingBytes { .. } => "vault_trailing_bytes",
260 Error::VaultDuplicateEntryId => "vault_duplicate_entry_id",
261 Error::VaultEntryNotFound => "vault_entry_not_found",
262 Error::VaultDuplicateField { .. } => "vault_duplicate_field",
263 Error::VaultCorrupt => "vault_corrupt",
264 Error::VaultBadKeyCheck => "vault_bad_key_check",
265 Error::VaultInvalidUtf8 => "vault_invalid_utf8",
266 Error::VaultDecryptionFailed => "vault_decryption_failed",
267 // ── The group tree ─────────────────────────────────────────────────
268 //
269 // Each of these is a different answer for the interface: an unknown group or entry
270 // means the view is stale and has to be refreshed, an immutable top group means the
271 // action does not exist there, and a cycle or a depth refusal means the move the user
272 // asked for cannot be drawn at all.
273 Error::VaultDuplicateGroupId => "vault_duplicate_group_id",
274 Error::VaultGroupNotFound => "vault_group_not_found",
275 Error::VaultGroupUnknownEntry => "vault_group_unknown_entry",
276 Error::VaultTopGroupImmutable => "vault_top_group_immutable",
277 Error::VaultGroupTooManyChildren { .. } => "vault_group_too_many_children",
278 Error::VaultGroupDuplicateChild => "vault_group_duplicate_child",
279 Error::VaultGroupCycle => "vault_group_cycle",
280 Error::VaultGroupDepthExceeded { .. } => "vault_group_depth_exceeded",
281 Error::MasterKeyWrongLength { .. } => "master_key_wrong_length",
282 Error::FieldTooLong { .. } => "field_too_long",
283 Error::VaultIo { .. } => "vault_io",
284 // ── Device identity, pairing, recovery, challenge-response ─────────
285 Error::DevicePublicKeyInvalid { .. } => "device_public_key_invalid",
286 Error::DeviceSignatureInvalid { .. } => "device_signature_invalid",
287 Error::Device(qualia_dik::Error::DevicePublicKeyInvalid { .. }) => {
288 "device_public_key_invalid"
289 }
290 Error::Device(qualia_dik::Error::DeviceSignatureInvalid { .. }) => {
291 "device_signature_invalid"
292 }
293 // ── Master key storage ─────────────────────────────────────────────
294 //
295 // The four ways holding the master key can fail are told apart because the
296 // host side does something different about each of them: a blob that will not
297 // unwrap needs a recovery path, a platform with no backend needs the other
298 // form (or another device), a store that will not hand the material back needs
299 // the wrapped form, and a machine with no usable provider needs the user told.
300 // A fifth kind, `device_error`, still covers every other key store failure.
301 Error::Device(qualia_dik::Error::InvalidWrappedKey { .. }) => "invalid_wrapped_key",
302 Error::Device(qualia_dik::Error::InvalidMasterKey { .. }) => "invalid_master_key",
303 Error::Device(qualia_dik::Error::KeyNotExportable { .. }) => "key_not_exportable",
304 Error::Device(qualia_dik::Error::HardwareUnavailable { .. }) => "hardware_unavailable",
305 Error::Device(qualia_dik::Error::UnsupportedPlatform { .. }) => "unsupported_platform",
306 Error::Device(_) => "device_error",
307 Error::PairingPayloadMalformed { .. } => "pairing_payload_malformed",
308 Error::PairingUnsupportedVersion { .. } => "pairing_unsupported_version",
309 Error::PairingAuthenticationFailed => "pairing_authentication_failed",
310 Error::RecoveryFileMalformed { .. } => "recovery_file_malformed",
311 Error::RecoveryParametersInvalid { .. } => "recovery_parameters_invalid",
312 Error::RecoveryPassphraseEmpty => "recovery_passphrase_empty",
313 Error::RecoveryAuthenticationFailed => "recovery_authentication_failed",
314 Error::RecoveryEncryptionFailed => "recovery_encryption_failed",
315 Error::RecoveryKdfFailed => "recovery_kdf_failed",
316 // ── The application lock's password wrap ────────────────────────────
317 //
318 // Each failure sends the unlock screen somewhere different: a file that is not a
319 // lock wrap is a state to report (restore or reset), a password that does not open
320 // it is a retry, and a damaged cost field is neither. They must not collapse into
321 // `"unknown"`.
322 Error::LockFileMalformed { .. } => "lock_file_malformed",
323 Error::LockParametersInvalid { .. } => "lock_parameters_invalid",
324 Error::LockPassphraseEmpty => "lock_passphrase_empty",
325 Error::LockPasswordRejected => "lock_password_rejected",
326 Error::LockEncryptionFailed => "lock_encryption_failed",
327 Error::LockKdfFailed => "lock_kdf_failed",
328 Error::UnsupportedFormatVersion { .. } => "unsupported_format_version",
329 Error::AuthMessageMalformed { .. } => "auth_message_malformed",
330 Error::AuthProofFailed => "auth_proof_failed",
331 Error::DeviceKeyMissing => "device_key_missing",
332 Error::DeviceNotRegistered => "device_not_registered",
333 Error::DeviceIdentityMismatch => "device_identity_mismatch",
334 Error::SecureStoreWriteFailed { .. } => "secure_store_write_failed",
335 Error::TimestampZero => "timestamp_zero",
336 // ── Another authenticator's files ──────────────────────────────────
337 //
338 // Both directions, because each one sends the user somewhere different: a file that is
339 // too large, one that nests too deeply, a password that is wrong rather than missing, an
340 // export that needs a password at all. A file-level failure is what the interface
341 // reports instead of a preview, so collapsing them into one kind would leave it unable
342 // to say which.
343 Error::ImportFileTooLarge { .. } => "import_file_too_large",
344 Error::ImportTooManyEntries { .. } => "import_too_many_entries",
345 Error::ImportTooDeeplyNested { .. } => "import_too_deeply_nested",
346 Error::ImportMalformed { .. } => "import_malformed",
347 Error::ImportUnsupportedVersion { .. } => "import_unsupported_version",
348 Error::ImportPasswordRequired => "import_password_required",
349 Error::ImportEncryptionUnsupported { .. } => "import_encryption_unsupported",
350 Error::ImportWrongPassword => "import_wrong_password",
351 Error::ImportCorrupted => "import_corrupted",
352 Error::ImportInvalidKdfParameters { .. } => "import_invalid_kdf_parameters",
353 Error::ExportPasswordRequired => "export_password_required",
354 Error::ExportPasswordEmpty => "export_password_empty",
355 Error::ExportQrTooLong { .. } => "export_qr_too_long",
356 Error::ExportTooLarge => "export_too_large",
357 Error::ExportParameterRefused { .. } => "export_parameter_refused",
358 // ── Portable vault containers ──────────────────────────────────────
359 //
360 // A `.q2fa` file that will not open sends the user somewhere different depending on
361 // why: the wrong kind of file, a version this build does not read, a damaged length, a
362 // bare vault file that carries no key, or a passphrase that is missing or wrong.
363 Error::PortableBadMagic => "portable_bad_magic",
364 Error::PortableUnsupportedVersion { .. } => "portable_unsupported_version",
365 Error::PortableTruncated => "portable_truncated",
366 Error::PortableLengthMismatch => "portable_length_mismatch",
367 Error::PortableTooLarge { .. } => "portable_too_large",
368 Error::PortableVaultFileWithoutKey => "portable_vault_file_without_key",
369 Error::PortablePasswordRequired => "portable_password_required",
370 // ── Synchronization and the channel ────────────────────────────────
371 //
372 // The sync family is mapped as a whole rather than only the variants the current
373 // screen branches on: a payload that fails to open, a log that is full and a
374 // channel that was used twice all mean different things to the user, and letting
375 // them collapse into `"unknown"` would leave the host side unable to tell them
376 // apart once it wants to.
377 Error::SyncBadMagic => "sync_bad_magic",
378 Error::SyncUnsupportedVersion { .. } => "sync_unsupported_version",
379 Error::SyncEmptyPayload => "sync_empty_payload",
380 Error::SyncTruncated => "sync_truncated",
381 Error::SyncRecordCountMismatch { .. } => "sync_record_count_mismatch",
382 Error::SyncTooManyRecords { .. } => "sync_too_many_records",
383 Error::SyncCompressionFailed => "sync_compression_failed",
384 Error::SyncDecompressionFailed => "sync_decompression_failed",
385 Error::SyncPayloadTooLarge { .. } => "sync_payload_too_large",
386 Error::SyncUnknownRecordKind { .. } => "sync_unknown_record_kind",
387 Error::SyncMalformed { .. } => "sync_malformed",
388 Error::SyncAuthenticationFailed => "sync_authentication_failed",
389 Error::SyncIconRequiresByteTransport => "sync_icon_requires_byte_transport",
390 Error::SyncAlreadyApplied => "sync_already_applied",
391 Error::SyncIdLogFull { .. } => "sync_id_log_full",
392 Error::SyncSenderMismatch => "sync_sender_mismatch",
393 Error::SyncUnknownPayloadKind { .. } => "sync_unknown_payload_kind",
394 Error::SyncNoSessionFound => "sync_no_session_found",
395 Error::TransportFailed { .. } => "transport_failed",
396 Error::TransportUnsupported { .. } => "transport_unsupported",
397 Error::TransportAlreadyUsed => "transport_already_used",
398 Error::TransportMessageTooLarge { .. } => "transport_message_too_large",
399 _ => "unknown",
400 }
401}
402
403impl FfiError {
404 /// Stable error identifier, for the client to dispatch UI text on.
405 ///
406 /// The client could read `FfiError.Core.detail.kind` directly, but that would
407 /// require every call site to match on the variant first. This method folds the
408 /// boundary's own errors into the same set of identifiers (`input_too_long`),
409 /// so the client needs a single `when`.
410 #[must_use]
411 pub fn kind(&self) -> &str {
412 match self {
413 FfiError::InputTooLong { .. } => "input_too_long",
414 FfiError::InputEmpty { .. } => "input_empty",
415 FfiError::WrongCodeKind { .. } => "wrong_code_kind",
416 FfiError::Core { detail } => &detail.kind,
417 FfiError::Qr { kind, .. } => kind,
418 FfiError::Lan { kind, .. } => kind,
419 FfiError::AuthBound { kind, .. } => kind,
420 FfiError::InvalidDeviceId { .. } => "invalid_device_id",
421 FfiError::UnknownConflict { .. } => "unknown_conflict",
422 }
423 }
424}
425
426impl From<Error> for FfiCoreError {
427 fn from(source: Error) -> Self {
428 FfiCoreError {
429 kind: core_error_kind(&source).to_owned(),
430 message: source.to_string(),
431 }
432 }
433}
434
435/// Lets [`FfiCoreError`] take part in an outer error's text through `Display`.
436///
437/// Written by hand rather than derived: `Display` outputs `message` and does not
438/// repeat `kind` — `kind` is meant for programs, and putting it into user-facing
439/// text only adds noise.
440///
441/// It is needed because `#[error("{source}")]` requires `source` to implement
442/// `Display`; otherwise thiserror treats it as `Error::source()` instead and
443/// reports an unsatisfied `as_dyn_error` trait bound.
444impl core::fmt::Display for FfiCoreError {
445 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
446 f.write_str(&self.message)
447 }
448}
449
450/// Errors originating in the FFI boundary itself.
451///
452/// Kept separate from the core's [`Error`] because "input too long" is a
453/// **boundary** concern, not a core semantic. Core errors are passed through in
454/// structured form as [`FfiCoreError`], which is not the same as discarding their
455/// classification.
456#[derive(Debug, thiserror::Error, uniffi::Error)]
457pub enum FfiError {
458 /// The input exceeds the limit set by this layer.
459 #[error("input too long: {kind} limit is {limit} characters, got {actual} characters")]
460 InputTooLong {
461 /// The restricted input category (`secret` / `uri`).
462 kind: String,
463 /// Maximum number of characters allowed.
464 limit: u64,
465 /// Actual number of characters.
466 actual: u64,
467 },
468
469 /// The host asked for a code the entry's kind cannot produce.
470 ///
471 /// A time-based entry has no counter to advance and an HOTP entry has no clock to read, so
472 /// calling the other function is a mistake in the host, not a fault in the vault. Named
473 /// rather than silently computing something: a code from the wrong scheme never matches, and
474 /// the user would see "the code is wrong" instead of "the wrong call was made".
475 #[error("this entry computes its code with {wanted}, not as {kind}")]
476 WrongCodeKind {
477 /// What the host asked for.
478 kind: String,
479 /// What the entry actually needs.
480 wanted: String,
481 },
482
483 /// A field the host side must fill in was empty.
484 ///
485 /// The one field this applies to is the PIN of an mOTP or Yandex entry: an empty text field
486 /// means the user cleared it, and storing an empty PIN would produce codes that never match
487 /// while looking like a working entry.
488 #[error("input is empty: {kind}")]
489 InputEmpty {
490 /// The field that was empty.
491 kind: String,
492 },
493
494 /// An error returned by the core.
495 ///
496 /// **No `#[from]`**: `#[from]` would make thiserror generate
497 /// `impl From<Error> for FfiError`, but UniFFI requires every field to be a
498 /// type that can cross the boundary, and the core `Error` is not. The
499 /// conversion is a hand-written `From` that first reduces the error to the
500 /// plain data of [`FfiCoreError`].
501 ///
502 /// The field is named `detail` rather than `source` on purpose: thiserror
503 /// always treats a field named `source` as `Error::source()` (requiring
504 /// `std::error::Error`), even when the text only writes `{source}`, and then
505 /// reports an unsatisfied `as_dyn_error` bound.
506 #[error("{detail}")]
507 Core {
508 /// Structured summary of the core error.
509 detail: FfiCoreError,
510 },
511
512 /// An error from the transport layer behind the code channel.
513 ///
514 /// Kept separate from [`FfiError::Core`] because it is a different layer with a
515 /// different set of failure modes: a payload that will not fit, an image with no code
516 /// in it, an image whose buffer does not match its dimensions. The message never
517 /// carries a payload.
518 #[error("{detail}")]
519 Qr {
520 /// Stable kind for the host to branch on, in `snake_case`.
521 kind: String,
522 /// Human-readable detail. Never contains a payload or a secret.
523 detail: String,
524 },
525
526 /// An error from the LAN channel.
527 ///
528 /// Separate from [`FfiError::Qr`] for the same reason that one is separate from
529 /// [`FfiError::Core`]: it is a different layer with a different set of failure modes — a
530 /// socket that cannot be opened, a connection to a peer that was never registered, a
531 /// multicast group that cannot be joined. The message never carries a payload.
532 #[error("{detail}")]
533 Lan {
534 /// Stable kind for the host to branch on, in `snake_case`.
535 kind: String,
536 /// Human-readable detail. Never contains a payload or a secret.
537 detail: String,
538 },
539
540 /// An error from the platform auth-bound key store behind `master.key.bio`.
541 ///
542 /// Separate from [`FfiError::Core`] because the platform authentication path has its
543 /// own failure modes (a cancelled gesture, an unavailable authenticator, a blob written
544 /// by another backend), and the host has to tell them apart from vault errors.
545 #[error("{detail}")]
546 AuthBound {
547 /// Stable kind for the host to branch on, in `snake_case`.
548 kind: String,
549 /// Human-readable detail. Never contains a key or a blob.
550 detail: String,
551 },
552
553 /// An identifier handed across the boundary is not in the form this layer documents.
554 ///
555 /// Device identifiers cross as 64 hexadecimal characters (see [`FfiTransport`]). A
556 /// value of another shape is a **host-side** mistake — a truncated copy, or a
557 /// fingerprint formatted with separators — and reading it as some other device would
558 /// attribute a payload to the wrong sender, which is what the sender check exists to
559 /// prevent.
560 #[error("{kind} is not 64 hexadecimal characters")]
561 InvalidDeviceId {
562 /// Which identifier was wrong (`device_id`), so the host side can point at it.
563 kind: String,
564 },
565
566 /// A decision was handed over for a conflict that is not waiting.
567 ///
568 /// The conflicts of a receive replace the ones before them, so a decision kept from an
569 /// older merge names nothing. Refusing it is deliberate: applying a decision to
570 /// whatever now sits under that identifier is how a user ends up with content they
571 /// never chose.
572 #[error("no conflict is waiting for entry {entry_id}")]
573 UnknownConflict {
574 /// The entry identifier the decision named, as it was passed in.
575 entry_id: String,
576 },
577}
578
579/// Conversion from a core error to an FFI error.
580///
581/// Hand-written rather than `#[from]`; see [`FfiError::Core`].
582impl From<Error> for FfiError {
583 fn from(source: Error) -> Self {
584 FfiError::Core {
585 detail: source.into(),
586 }
587 }
588}
589
590/// Hash algorithm selection passed in over FFI.
591///
592/// The core's [`Algorithm`] is not exposed directly, for two reasons:
593///
594/// 1. The extra layer keeps the Rust API unaffected by renames of the client-side enum.
595/// 2. If the core ever turns it into an enum carrying data, the FFI contract stays unchanged.
596#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
597pub enum FfiAlgorithm {
598 /// HMAC-SHA-1 (the RFC 6238 default; almost every real server supports only this).
599 Sha1,
600 /// HMAC-SHA-256.
601 Sha256,
602 /// HMAC-SHA-512.
603 Sha512,
604}
605
606impl From<FfiAlgorithm> for Algorithm {
607 fn from(value: FfiAlgorithm) -> Self {
608 match value {
609 FfiAlgorithm::Sha1 => Algorithm::Sha1,
610 FfiAlgorithm::Sha256 => Algorithm::Sha256,
611 FfiAlgorithm::Sha512 => Algorithm::Sha512,
612 }
613 }
614}
615
616/// Number of digits in a generated code.
617#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
618pub enum FfiDigits {
619 /// 5 digits (Steam Guard).
620 Five,
621 /// 6 digits (the default).
622 Six,
623 /// 7 digits (used by some banks).
624 Seven,
625 /// 8 digits.
626 Eight,
627 /// 9 digits.
628 Nine,
629 /// A count a form does not offer. Only another application's export or a crafted file
630 /// produces one, and it is carried through rather than rounded: rounding would silently
631 /// change what the entry computes.
632 Other {
633 /// The count, between 1 and 9.
634 digits: u8,
635 },
636}
637
638impl FfiDigits {
639 /// The digit count as the core models it.
640 ///
641 /// **Fallible on purpose.** A count outside 1..=9 is refused here rather than rounded to a
642 /// count the core accepts: an entry silently saved with six digits when the caller asked for
643 /// zero or ten produces codes that never match, and nothing on screen would say why. The
644 /// fallback this replaced did exactly that.
645 ///
646 /// # Errors
647 ///
648 /// The core's own [`Error::DigitsZero`] or [`Error::DigitsTooMany`], which say which count
649 /// was refused and why ten cannot be produced at all.
650 fn to_core(self) -> Result<Digits, FfiError> {
651 match self {
652 FfiDigits::Five => Ok(Digits::FIVE),
653 FfiDigits::Six => Ok(Digits::SIX),
654 FfiDigits::Seven => Ok(Digits::SEVEN),
655 FfiDigits::Eight => Ok(Digits::EIGHT),
656 FfiDigits::Nine => Ok(Digits::new(9).expect("nine is a digit count the core accepts")),
657 FfiDigits::Other { digits } => Ok(Digits::new(digits)?),
658 }
659 }
660}
661
662impl From<Digits> for FfiDigits {
663 fn from(value: Digits) -> Self {
664 match value.as_byte() {
665 5 => FfiDigits::Five,
666 6 => FfiDigits::Six,
667 7 => FfiDigits::Seven,
668 8 => FfiDigits::Eight,
669 9 => FfiDigits::Nine,
670 digits => FfiDigits::Other { digits },
671 }
672 }
673}
674
675/// The kind of an entry, with the parameters that belong to it.
676///
677/// The kind decides how a code is computed: a time step, a counter, and for two of them a PIN.
678/// The former flat `period` / `counter` pair could not express an mOTP entry, a Yandex OTP entry
679/// or a Steam Guard entry, so those arrived as ordinary TOTP entries and produced wrong codes.
680///
681/// # The PIN is an input, never an output
682///
683/// `pin` is written by the host when it creates or changes an entry, and the core never hands one
684/// back: a PIN is key material with no display purpose, and the one place that needs to read it is
685/// the code computation, which happens inside the core. [`FfiVaultEntry::pin_set`] says whether
686/// one is stored, so a form can show that a PIN exists without being able to read it.
687///
688/// On **update**, `pin: None` means "keep the stored PIN": a host that edits a note must not have
689/// to ask the user for a PIN it was never shown, and must not silently drop it either.
690#[derive(Clone, PartialEq, Eq, uniffi::Enum)]
691pub enum FfiEntryKind {
692 /// Time-based (RFC 6238).
693 Totp {
694 /// Time step in seconds.
695 period: u64,
696 },
697 /// Counter-based (RFC 4226).
698 Hotp {
699 /// The counter.
700 counter: u64,
701 },
702 /// mOTP: MD5, a ten-second step, six hexadecimal characters, and a PIN.
703 Motp {
704 /// Time step in seconds, ten by definition.
705 period: u64,
706 /// The PIN to store. See the type documentation.
707 pin: Option<String>,
708 },
709 /// Yandex OTP: SHA-256, eight lower-case letters, and a PIN.
710 Yandex {
711 /// Time step in seconds, thirty by definition.
712 period: u64,
713 /// The PIN to store. See the type documentation.
714 pin: Option<String>,
715 },
716 /// Steam Guard: SHA-1, five characters, a thirty-second step.
717 Steam,
718}
719
720impl core::fmt::Debug for FfiEntryKind {
721 /// Prints the shape only: the PIN of an mOTP or Yandex entry is key material and must not
722 /// reach a log, a panic message or a crash dump.
723 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
724 let (name, period) = match self {
725 Self::Totp { period } => ("Totp", Some(*period)),
726 Self::Motp { period, .. } => ("Motp", Some(*period)),
727 Self::Yandex { period, .. } => ("Yandex", Some(*period)),
728 Self::Hotp { .. } => ("Hotp", None),
729 Self::Steam => ("Steam", None),
730 };
731 let mut debug = formatter.debug_struct(name);
732 if let Some(period) = period {
733 debug.field("period", &period);
734 }
735 if let Self::Hotp { counter } = self {
736 debug.field("counter", counter);
737 }
738 if matches!(
739 self,
740 Self::Motp { pin: Some(_), .. } | Self::Yandex { pin: Some(_), .. }
741 ) {
742 debug.field("pin", &"<set>");
743 }
744 debug.finish_non_exhaustive()
745 }
746}
747
748/// A parsed `otpauth://` entry.
749///
750/// The fields correspond one-to-one to the core [`uri::OtpAuth`]; the only differences are that
751/// `secret` appears as a Base32 string rather than as an `OtpSecret`, and that the kind carries
752/// the PIN as an input rather than as a readable value (see [`FfiEntryKind`]).
753#[derive(Clone, PartialEq, Eq, uniffi::Record)]
754pub struct FfiOtpAuth {
755 /// The kind, with its period, counter and PIN.
756 pub kind: FfiEntryKind,
757 /// The secret, Base32 encoded (upper case).
758 pub secret_base32: String,
759 /// Issuer (service name), such as `GitHub`.
760 pub issuer: Option<String>,
761 /// Account name, such as `alice@example.com`.
762 pub account: String,
763 /// Hash algorithm in use.
764 pub algorithm: FfiAlgorithm,
765 /// Number of digits in a code.
766 pub digits: FfiDigits,
767}
768
769impl FfiEntryKind {
770 /// The kind as the host sees it.
771 ///
772 /// A stored PIN is reported as [`FfiVaultEntry::pin_set`] rather than as a value: the core
773 /// never hands one out, because the only thing that needs to read it is the code computation,
774 /// which happens there.
775 fn from_core(kind: &EntryKind) -> Self {
776 match kind {
777 EntryKind::Totp { period } => Self::Totp { period: *period },
778 EntryKind::Hotp { counter } => Self::Hotp { counter: *counter },
779 EntryKind::Motp { period, .. } => Self::Motp {
780 period: *period,
781 pin: None,
782 },
783 EntryKind::Yandex { period, .. } => Self::Yandex {
784 period: *period,
785 pin: None,
786 },
787 EntryKind::Steam => Self::Steam,
788 }
789 }
790
791 /// The kind as the core sees it.
792 ///
793 /// `existing` is the kind the entry has now, when there is one. Its PIN is **moved** into the
794 /// result when the host supplied none, so that editing a note neither drops the PIN silently
795 /// nor forces the user to retype a value the host was never shown.
796 fn into_core(self, existing: Option<EntryKind>) -> Result<EntryKind, FfiError> {
797 Ok(match self {
798 Self::Totp { period } => EntryKind::Totp { period },
799 Self::Hotp { counter } => EntryKind::Hotp { counter },
800 Self::Steam => EntryKind::Steam,
801 Self::Motp { period, pin } => EntryKind::Motp {
802 period,
803 pin: motp_pin(pin, existing)?,
804 },
805 Self::Yandex { period, pin } => EntryKind::Yandex {
806 period,
807 pin: yandex_pin(pin, existing)?,
808 },
809 })
810 }
811}
812
813/// The mOTP PIN to store: what the host supplied, or the one the entry already has.
814fn motp_pin(
815 supplied: Option<String>,
816 existing: Option<EntryKind>,
817) -> Result<Option<MotpPin>, FfiError> {
818 match supplied {
819 Some(pin) => {
820 check_len("pin", &pin, MAX_VAULT_FIELD_CHARS)?;
821 if pin.is_empty() {
822 return Err(FfiError::InputEmpty {
823 kind: "pin".to_owned(),
824 });
825 }
826 Ok(Some(MotpPin::new(pin)))
827 }
828 None => Ok(match existing {
829 Some(EntryKind::Motp { pin, .. }) => pin,
830 _ => None,
831 }),
832 }
833}
834
835/// The Yandex PIN to store: what the host supplied, or the one the entry already has.
836fn yandex_pin(
837 supplied: Option<String>,
838 existing: Option<EntryKind>,
839) -> Result<Option<YandexPin>, FfiError> {
840 match supplied {
841 Some(pin) => {
842 check_len("pin", &pin, MAX_VAULT_FIELD_CHARS)?;
843 if pin.is_empty() {
844 return Err(FfiError::InputEmpty {
845 kind: "pin".to_owned(),
846 });
847 }
848 Ok(Some(YandexPin::new(pin)))
849 }
850 None => Ok(match existing {
851 Some(EntryKind::Yandex { pin, .. }) => pin,
852 _ => None,
853 }),
854 }
855}
856
857impl core::fmt::Debug for FfiOtpAuth {
858 /// Prints everything except the secret: it is the one field that must not reach a log, a
859 /// panic message or a crash dump, and the import preview makes this type travel through
860 /// diagnostics for the first time.
861 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
862 formatter
863 .debug_struct("FfiOtpAuth")
864 .field("kind", &self.kind)
865 .field("secret_base32", &"<redacted>")
866 .field("issuer", &self.issuer)
867 .field("account", &self.account)
868 .field("algorithm", &self.algorithm)
869 .field("digits", &self.digits)
870 .finish_non_exhaustive()
871 }
872}
873
874/// Checks a string length limit and returns [`FfiError::InputTooLong`] when it is
875/// exceeded.
876///
877/// The limit counts **characters**, not bytes: strings arriving from the host
878/// side may contain multi-byte characters, and counting bytes would make such
879/// input hit the limit sooner than the reported character count suggests.
880fn check_len(kind: &str, value: &str, limit: usize) -> Result<(), FfiError> {
881 let actual = value.chars().count();
882 if actual > limit {
883 return Err(FfiError::InputTooLong {
884 kind: kind.to_owned(),
885 limit: limit as u64,
886 actual: actual as u64,
887 });
888 }
889 Ok(())
890}
891
892/// Parses an `otpauth://` URI.
893///
894/// Parses and validates the Google Authenticator Key URI Format, including the
895/// issuer/account prefix consistency check, rejection of repeated parameters and
896/// percent decoding.
897///
898/// # Errors
899///
900/// Returns [`FfiError::InputTooLong`] when the input is too long; otherwise
901/// returns a core error ([`FfiError::Core`]) whose meaning is documented on the
902/// core `Error`.
903#[uniffi::export]
904pub fn parse_otpauth_uri(uri: String) -> Result<FfiOtpAuth, FfiError> {
905 check_len("uri", &uri, MAX_URI_CHARS)?;
906 let parsed = uri::parse(&uri)?;
907 Ok(FfiOtpAuth {
908 kind: FfiEntryKind::from_core(&parsed.kind),
909 secret_base32: parsed.secret.to_base32(),
910 issuer: parsed.issuer,
911 account: parsed.account,
912 algorithm: match parsed.algorithm {
913 Algorithm::Sha1 => FfiAlgorithm::Sha1,
914 Algorithm::Sha256 => FfiAlgorithm::Sha256,
915 Algorithm::Sha512 => FfiAlgorithm::Sha512,
916 },
917 digits: parsed.digits.into(),
918 })
919}
920
921/// Serializes an [`FfiOtpAuth`] back into an `otpauth://` URI.
922///
923/// # Errors
924///
925/// The core rejects an account name containing `:` — that character is
926/// indistinguishable from the `issuer:account` separator, so encoding it would
927/// change which part of a round-tripped URI belongs to the issuer and which to
928/// the account. The rejection is intentional.
929#[uniffi::export]
930pub fn serialize_otpauth_uri(auth: FfiOtpAuth) -> Result<String, FfiError> {
931 check_len("secret", &auth.secret_base32, MAX_SECRET_CHARS)?;
932 let secret = OtpSecret::from_base32(&auth.secret_base32)?;
933 let core_auth = uri::OtpAuth {
934 kind: auth.kind.into_core(None)?,
935 secret,
936 issuer: auth.issuer,
937 account: auth.account,
938 algorithm: auth.algorithm.into(),
939 digits: auth.digits.to_core()?,
940 };
941 Ok(uri::serialize(&core_auth)?)
942}
943
944/// Computes a TOTP code.
945///
946/// `unix_time` is Unix seconds and is **supplied by the host side**: the core
947/// does not read a clock, so time always enters as a parameter, which is what
948/// makes the RFC 6238 test vectors verifiable. The host side should pass the
949/// system clock in seconds.
950///
951/// The return value is a fixed-width decimal string (leading zeros included)
952/// whose length is given by `digits`.
953#[uniffi::export]
954pub fn totp_code(
955 secret_base32: String,
956 unix_time: u64,
957 period: u64,
958 algorithm: FfiAlgorithm,
959 digits: FfiDigits,
960) -> Result<String, FfiError> {
961 check_len("secret", &secret_base32, MAX_SECRET_CHARS)?;
962 let secret = OtpSecret::from_base32(&secret_base32)?;
963 Ok(otp::totp_code(
964 &secret,
965 unix_time,
966 period,
967 algorithm.into(),
968 digits.to_core()?,
969 )?)
970}
971
972/// Computes an HOTP code.
973#[uniffi::export]
974pub fn hotp_code(
975 secret_base32: String,
976 counter: u64,
977 algorithm: FfiAlgorithm,
978 digits: FfiDigits,
979) -> Result<String, FfiError> {
980 check_len("secret", &secret_base32, MAX_SECRET_CHARS)?;
981 let secret = OtpSecret::from_base32(&secret_base32)?;
982 Ok(otp::hotp_code(
983 &secret,
984 counter,
985 algorithm.into(),
986 digits.to_core()?,
987 ))
988}
989
990/// Computes a Steam Guard code (5 characters, custom alphabet).
991///
992/// `period` is normally 30, the time step Steam uses.
993#[uniffi::export]
994pub fn steam_guard_code(
995 secret_base32: String,
996 unix_time: u64,
997 period: u64,
998 algorithm: FfiAlgorithm,
999) -> Result<String, FfiError> {
1000 check_len("secret", &secret_base32, MAX_SECRET_CHARS)?;
1001 let secret = OtpSecret::from_base32(&secret_base32)?;
1002 Ok(otp::steam_guard_code(
1003 &secret,
1004 unix_time,
1005 period,
1006 algorithm.into(),
1007 )?)
1008}
1009
1010/// Verifies a user-supplied code, allowing for clock drift.
1011///
1012/// `expected_counter` is the counter for the current time (`unix_time / period`).
1013/// `window` is the number of steps of drift allowed (RFC 6238 suggests 1, that
1014/// is ±1 step).
1015///
1016/// The return value is the offset that matched: `0` for the current step, `-1`
1017/// for one step behind, `1` for one step ahead, and so on. The offset is
1018/// **returned instead of a boolean** so that the host side can calibrate that
1019/// entry's clock deviation rather than only learn right or wrong.
1020///
1021/// The comparison is constant time and does not return early on a matching
1022/// prefix. `None` means verification failed, including a malformed input code.
1023#[uniffi::export]
1024pub fn verify_code_with_drift(
1025 secret_base32: String,
1026 expected_counter: u64,
1027 window: u64,
1028 algorithm: FfiAlgorithm,
1029 digits: FfiDigits,
1030 provided_code: String,
1031) -> Result<Option<i64>, FfiError> {
1032 check_len("secret", &secret_base32, MAX_SECRET_CHARS)?;
1033 let secret = OtpSecret::from_base32(&secret_base32)?;
1034 Ok(otp::verify_with_drift(
1035 &secret,
1036 expected_counter,
1037 window,
1038 algorithm.into(),
1039 digits.to_core()?,
1040 &provided_code,
1041 ))
1042}
1043
1044/// Compares two code strings for equality in constant time.
1045///
1046/// For host-side code that has already computed the expected code itself. Use
1047/// [`verify_code_with_drift`] when clock drift must be tolerated.
1048#[uniffi::export]
1049#[must_use]
1050pub fn codes_match(expected: String, provided: String) -> bool {
1051 otp::verify_code(&expected, &provided)
1052}
1053
1054/// Recommended minimum secret length in bytes for the given algorithm.
1055///
1056/// Intended for a **warning** on import rather than a rejection: the core does
1057/// not enforce a minimum length, since short secrets exist in practice.
1058#[uniffi::export]
1059#[must_use]
1060pub fn recommended_min_secret_len(algorithm: FfiAlgorithm) -> u64 {
1061 otp::recommended_min_secret_len(algorithm.into()) as u64
1062}
1063
1064/// Reports whether the secret is shorter than the recommended length.
1065///
1066/// The host side cannot compare lengths itself: it only has the Base32 string,
1067/// and the Base32 length is not linearly related to the byte length (5 bits per
1068/// character versus 8 bits per byte).
1069#[uniffi::export]
1070pub fn is_secret_shorter_than_recommended(
1071 secret_base32: String,
1072 algorithm: FfiAlgorithm,
1073) -> Result<bool, FfiError> {
1074 check_len("secret", &secret_base32, MAX_SECRET_CHARS)?;
1075 let secret = OtpSecret::from_base32(&secret_base32)?;
1076 Ok(secret.is_shorter_than_recommended(algorithm.into()))
1077}
1078
1079/// The **byte** length of the secret (not the Base32 string length).
1080///
1081/// Intended for host-side messages such as "20 bytes / 20 recommended".
1082#[uniffi::export]
1083pub fn secret_byte_len(secret_base32: String) -> Result<u64, FfiError> {
1084 check_len("secret", &secret_base32, MAX_SECRET_CHARS)?;
1085 let secret = OtpSecret::from_base32(&secret_base32)?;
1086 Ok(secret.len() as u64)
1087}
1088
1089/// Generates a random 256-bit secret and returns its Base32 representation.
1090///
1091/// Intended for generating a secret when a new entry is created. Randomness
1092/// comes from the OS CSPRNG (`getrandom`), not from a user-space PRNG.
1093///
1094/// 256 bits is the output length of SHA-256 and is above the recommended
1095/// minimum of every supported algorithm: the largest of the recommended minima
1096/// is 64 bytes (512 bits), so 32 bytes is equal in length to SHA-256's output,
1097/// shorter than SHA-512's block but still above its recommended minimum.
1098#[uniffi::export]
1099pub fn generate_secret_base32() -> Result<String, FfiError> {
1100 let mut bytes = [0_u8; 32];
1101 qualia2fa_core::getrandom::fill(&mut bytes).map_err(|_| FfiError::Core {
1102 detail: Error::RandomnessUnavailable.into(),
1103 })?;
1104 let secret = OtpSecret::from_bytes(bytes.to_vec())?;
1105 Ok(secret.to_base32())
1106}
1107
1108/// The core library version, for compatibility checks and diagnostics.
1109///
1110/// This is the version of `qualia2fa-core`, not of this FFI crate: the semantic
1111/// version the host side needs to align with is the core's.
1112#[uniffi::export]
1113#[must_use]
1114pub fn core_version() -> String {
1115 qualia2fa_core::VERSION.to_owned()
1116}
1117
1118// ═══════════════════════════════════════════════════════════════════════════
1119// The code channel: turning text into a QR code and back
1120// ═══════════════════════════════════════════════════════════════════════════
1121
1122/// A rendered QR code, as pixels the host can draw.
1123///
1124/// The pixels are **grayscale**, one byte per pixel, `0` for black and `255` for white.
1125#[derive(Clone, uniffi::Record)]
1126pub struct FfiQrImage {
1127 /// Image width in pixels.
1128 pub width: u32,
1129 /// Image height in pixels.
1130 pub height: u32,
1131 /// Grayscale pixels, row major, `width * height` bytes.
1132 pub pixels: Vec<u8>,
1133}
1134
1135impl core::fmt::Debug for FfiQrImage {
1136 /// Prints the dimensions only: the pixels are the code, and during pairing the code
1137 /// carries session material that must not reach a log or a panic message.
1138 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1139 formatter
1140 .debug_struct("FfiQrImage")
1141 .field("width", &self.width)
1142 .field("height", &self.height)
1143 .finish_non_exhaustive()
1144 }
1145}
1146
1147/// The error correction level of a code, named by what it survives.
1148#[derive(Debug, Clone, Copy, uniffi::Enum)]
1149pub enum FfiEcLevel {
1150 /// About 7% of the code may be damaged.
1151 Low,
1152 /// About 15%.
1153 Medium,
1154 /// About 25%.
1155 Quartile,
1156 /// About 30%.
1157 High,
1158}
1159
1160impl From<FfiEcLevel> for qualia_transport::qr::EcLevel {
1161 fn from(value: FfiEcLevel) -> Self {
1162 match value {
1163 FfiEcLevel::Low => Self::Low,
1164 FfiEcLevel::Medium => Self::Medium,
1165 FfiEcLevel::Quartile => Self::Quartile,
1166 FfiEcLevel::High => Self::High,
1167 }
1168 }
1169}
1170
1171/// Encodes `text` into a single QR code and renders it as grayscale pixels.
1172///
1173/// `scale` is how many pixels one module becomes, `quiet_zone` how many modules of
1174/// margin surround the code. Text that does not fit in `max_version` is an error: the
1175/// code is never truncated, and the error correction level is not silently lowered.
1176///
1177/// # Errors
1178///
1179/// Returns [`FfiError::Qr`] with kind `payload_too_large` when the text does not fit, and
1180/// `bad_render_geometry` when `scale` or `quiet_zone` is zero.
1181#[uniffi::export]
1182pub fn qr_encode(
1183 text: String,
1184 ec_level: FfiEcLevel,
1185 max_version: u8,
1186 scale: u16,
1187 quiet_zone: u16,
1188) -> Result<FfiQrImage, FfiError> {
1189 let profile =
1190 qualia_transport::qr::QrProfile::new(ec_level.into(), max_version).map_err(qr_error)?;
1191 let symbol = profile.encode_payload(text.as_bytes()).map_err(qr_error)?;
1192 let image = symbol.render(scale, quiet_zone).map_err(qr_error)?;
1193 Ok(FfiQrImage {
1194 width: image.width(),
1195 height: image.height(),
1196 pixels: image.pixels().to_vec(),
1197 })
1198}
1199
1200/// Reads the text back out of a grayscale image.
1201///
1202/// The text is carried inside the transport's own envelope (`Q2FA1:<index>/<count>:<BASE32>`),
1203/// which is what a code drawn by [`qr_encode`] contains and what any scanner sees; this
1204/// function unwraps it so the caller gets back the text it encoded.
1205///
1206/// The input is **pixels, not an image file**: decoding the file format belongs to the
1207/// platform (`ImageIO` on the JVM, `BitmapFactory` on Android, `UIImage` on iOS), because
1208/// format decoders are where the historical vulnerabilities live. The host converts an
1209/// imported image to grayscale and hands the result here.
1210///
1211/// # Errors
1212///
1213/// Returns [`FfiError::Qr`] with kind `bad_image_geometry` when the buffer does not match
1214/// the dimensions, `not_our_qr_text` when the code is not this application's, `no_qr_found`
1215/// when there is no code in the image, and `qr_decoder_panicked` when the decoder hit the
1216/// assertion that degenerate images trigger. That last one is contained rather than fixed:
1217/// the panic is caught here, and the fix belongs upstream.
1218#[uniffi::export]
1219pub fn qr_decode(pixels: Vec<u8>, width: u32, height: u32) -> Result<String, FfiError> {
1220 let image =
1221 qualia_transport::image::GrayImage::from_luma8(width, height, pixels).map_err(qr_error)?;
1222 let payload = qualia_transport::qr::scan::decode_payload(&image).map_err(qr_error)?;
1223 String::from_utf8(payload).map_err(|_| FfiError::Qr {
1224 kind: "payload_not_utf8".to_owned(),
1225 detail: "the code carried bytes that are not UTF-8 text".to_owned(),
1226 })
1227}
1228
1229/// Reads **any** text out of a grayscale image, whichever application drew the code.
1230///
1231/// This is the entry point for a screen that receives text and decides what it means for
1232/// itself: an `otpauth://` link to import, a code handed over by someone else's server. Use
1233/// [`qr_decode`] instead when the caller wants *this* application's payload, because that one
1234/// skips foreign codes rather than handing back the first one it finds.
1235///
1236/// The input is pixels, not an image file, for the reason given on [`qr_decode`].
1237///
1238/// # Errors
1239///
1240/// The same kinds as [`qr_decode`] except `not_our_qr_text`, which this entry point cannot
1241/// report: a code of another application is exactly what it is here to read.
1242#[uniffi::export]
1243pub fn qr_decode_text(pixels: Vec<u8>, width: u32, height: u32) -> Result<String, FfiError> {
1244 let image =
1245 qualia_transport::image::GrayImage::from_luma8(width, height, pixels).map_err(qr_error)?;
1246 qualia_transport::qr::scan::decode_text(&image).map_err(qr_error)
1247}
1248
1249/// Takes the envelope off a single-frame code, answering with what was put in.
1250///
1251/// The counterpart of [`qr_encode`]: a screen that reads a code gets the envelope, which says
1252/// how many codes the payload was split into, and a screen that shows one code at a time wants
1253/// back the text it drew. A code of a set (`2/5`) is not this function's business and is
1254/// reported as incomplete rather than unwrapped.
1255///
1256/// # Errors
1257///
1258/// Returns [`FfiError::Qr`] with kind `not_our_qr_text` for a text that is not this
1259/// application's envelope, `incomplete_frames` for one that is part of a set, and
1260/// `payload_not_utf8` for one whose contents are not text.
1261#[uniffi::export]
1262pub fn qr_unwrap_text(text: String) -> Result<String, FfiError> {
1263 let mut reassembler = qualia_transport::frame::Reassembler::from_first_frame();
1264 let payload = reassembler
1265 .push(text.trim())
1266 .map_err(qr_error)?
1267 .ok_or_else(|| FfiError::Qr {
1268 kind: "incomplete_frames".to_owned(),
1269 detail: "the code is one of several, so it carries only part of the payload".to_owned(),
1270 })?;
1271 String::from_utf8(payload).map_err(|_| FfiError::Qr {
1272 kind: "payload_not_utf8".to_owned(),
1273 detail: "the code carried bytes that are not UTF-8 text".to_owned(),
1274 })
1275}
1276/// What one code of a multi-frame payload holds, in bytes.
1277///
1278/// A payload larger than this has to be split, and this is the number to give
1279/// [`FfiFrameCollector::new`]: both sides have to agree on it, and it depends only on the
1280/// profile the codes are drawn with.
1281#[uniffi::export]
1282pub fn qr_frame_capacity(ec_level: FfiEcLevel, max_version: u8) -> Result<u32, FfiError> {
1283 let profile =
1284 qualia_transport::qr::QrProfile::new(ec_level.into(), max_version).map_err(qr_error)?;
1285 Ok(profile.frame_payload_capacity() as u32)
1286}
1287
1288/// One image of a payload that needs more than one code.
1289#[derive(uniffi::Record)]
1290pub struct FfiQrFrame {
1291 /// The code's number in the sequence, counting from one.
1292 pub index: u32,
1293 /// How many codes the payload was split into.
1294 pub count: u32,
1295 /// The image to draw for this code.
1296 pub image: FfiQrImage,
1297}
1298
1299/// Draws a payload as a sequence of codes, for payloads that one code cannot carry.
1300///
1301/// A payload that fits in a single code comes back as one frame, so the caller does not have to
1302/// decide which way to draw it: `count` says what the sequence is, and a sequence of one is the
1303/// same code [`qr_encode`] would draw.
1304///
1305/// # Errors
1306///
1307/// Returns [`FfiError::Qr`] with kind `payload_too_large` when the payload does not fit in
1308/// `max_frames` codes, and `bad_render_geometry` when `scale` or `quiet_zone` is zero.
1309#[uniffi::export]
1310pub fn qr_encode_frames(
1311 text: String,
1312 ec_level: FfiEcLevel,
1313 max_version: u8,
1314 max_frames: u32,
1315 scale: u16,
1316 quiet_zone: u16,
1317) -> Result<Vec<FfiQrFrame>, FfiError> {
1318 let profile =
1319 qualia_transport::qr::QrProfile::new(ec_level.into(), max_version).map_err(qr_error)?;
1320 let symbols = profile
1321 .encode_payload_frames(text.as_bytes(), max_frames)
1322 .map_err(qr_error)?;
1323 let count = symbols.len() as u32;
1324 symbols
1325 .into_iter()
1326 .enumerate()
1327 .map(|(position, symbol)| {
1328 let image = symbol.render(scale, quiet_zone).map_err(qr_error)?;
1329 Ok(FfiQrFrame {
1330 index: position as u32 + 1,
1331 count,
1332 image: FfiQrImage {
1333 width: image.width(),
1334 height: image.height(),
1335 pixels: image.pixels().to_vec(),
1336 },
1337 })
1338 })
1339 .collect()
1340}
1341
1342/// What one read code was, and what it did to the set it belongs to.
1343#[derive(uniffi::Record)]
1344pub struct FfiFrameStep {
1345 /// The code's number in the sequence, counting from one.
1346 pub index: u32,
1347 /// How many codes the sequence has.
1348 pub count: u32,
1349 /// The payload, present only in the call that completes the set.
1350 pub payload: Option<Vec<u8>>,
1351}
1352/// Collects the codes of a multi-frame payload, in any order and with repeats.
1353///
1354/// The caller reads one code at a time and hands each text to [`FfiFrameCollector::push`]; the
1355/// call that completes the set answers with the payload. Codes may arrive twice, out of order,
1356/// or both, which is what collecting them by camera looks like: the collector keeps what it has
1357/// and reports what is missing, so a screen can show the user how far along it is.
1358#[derive(uniffi::Object)]
1359pub struct FfiFrameCollector {
1360 inner: Mutex<qualia_transport::frame::Reassembler>,
1361}
1362
1363#[uniffi::export]
1364impl FfiFrameCollector {
1365 /// A collector for codes drawn with a frame capacity of `capacity` bytes, which
1366 /// [`qr_frame_capacity`] reports for the profile they were drawn with.
1367 #[uniffi::constructor]
1368 pub fn new(capacity: u32) -> Arc<Self> {
1369 Arc::new(Self {
1370 inner: Mutex::new(qualia_transport::frame::Reassembler::new(capacity as usize)),
1371 })
1372 }
1373
1374 /// Takes one read code, answering with what it was and whether it completed the set.
1375 ///
1376 /// # Errors
1377 ///
1378 /// Returns [`FfiError::Qr`] with kind `not_our_qr_text` for a code of another application,
1379 /// and `malformed_frame_header` for one of this application's that is not a frame.
1380 pub fn push(&self, text: String) -> Result<FfiFrameStep, FfiError> {
1381 let frame = qualia_transport::frame::decode_frame(text.trim()).map_err(qr_error)?;
1382 let mut inner = self.lock();
1383 let payload = inner.push(text.trim()).map_err(qr_error)?;
1384 Ok(FfiFrameStep {
1385 index: frame.index,
1386 count: frame.total,
1387 payload,
1388 })
1389 }
1390
1391 /// How many codes have been accepted so far.
1392 pub fn received(&self) -> u32 {
1393 self.lock().received() as u32
1394 }
1395
1396 /// How many codes the set has, once one of them has said so.
1397 pub fn expected(&self) -> Option<u32> {
1398 self.lock().expected_frames()
1399 }
1400
1401 /// The numbers of the codes still missing, once the count is known.
1402 pub fn missing(&self) -> Vec<u32> {
1403 self.lock().missing()
1404 }
1405}
1406
1407impl FfiFrameCollector {
1408 /// Takes the lock, ignoring poisoning (see [`FfiVault::lock`]).
1409 fn lock(&self) -> std::sync::MutexGuard<'_, qualia_transport::frame::Reassembler> {
1410 self.inner
1411 .lock()
1412 .unwrap_or_else(std::sync::PoisonError::into_inner)
1413 }
1414}
1415
1416/// Reduces a transport error to a kind and a message, without the payload.
1417///
1418/// The transport error is `#[non_exhaustive]`, so the wildcard arm is required by the
1419/// compiler rather than optional; a new variant therefore lands in `qr_error` until it is
1420/// mapped, which is what the host sees as an unnamed failure. Its own test pins the kinds
1421/// that matter.
1422fn qr_error(source: qualia_transport::Error) -> FfiError {
1423 use qualia_transport::Error as QrError;
1424
1425 let kind = match &source {
1426 QrError::PayloadTooLarge { .. } => "payload_too_large",
1427 QrError::TooManyFrames { .. } => "too_many_frames",
1428 QrError::NotOurQrText => "not_our_qr_text",
1429 QrError::MalformedFrameHeader => "malformed_frame_header",
1430 QrError::MultiFrameUnsupported { .. } => "multi_frame_unsupported",
1431 QrError::UnknownEcLevel { .. } => "unknown_ec_level",
1432 QrError::VersionOutOfRange { .. } => "version_out_of_range",
1433 QrError::BadRenderGeometry { .. } => "bad_render_geometry",
1434 QrError::BadImageGeometry { .. } => "bad_image_geometry",
1435 QrError::NoQrFound => "no_qr_found",
1436 QrError::QrDecoderPanicked => "qr_decoder_panicked",
1437 QrError::QrNotText => "qr_not_text",
1438 _ => "qr_error",
1439 };
1440
1441 FfiError::Qr {
1442 kind: kind.to_owned(),
1443 detail: source.to_string(),
1444 }
1445}
1446
1447// ═══════════════════════════════════════════════════════════════════════════
1448// Device identity, registration and login
1449// ═══════════════════════════════════════════════════════════════════════════
1450//
1451// The master key stays inside Rust wherever possible. The core `flow` module
1452// takes its platform dependencies as two trait parameters, so orchestration
1453// completes without the master key leaving Rust. This section has the same
1454// shape:
1455//
1456// - [`FfiVault`] **holds** the master key on the Rust side, and both entry
1457// operations and serialization happen inside Rust. The host side only
1458// receives vault file bytes and entry fields, never the master key: on the
1459// open / add entry / serialize path the master key does not cross the
1460// boundary at all.
1461// - The master key crosses the boundary in exactly two places, both
1462// unavoidable: registration hands it to the platform key store
1463// ([`FfiSecureStore`]), and login reads it back out of the platform key
1464// store. Its permanent home is the host side's hardware key store, which only
1465// the host side can reach.
1466// - [`FfiSecureStore`] therefore exchanges **bytes** rather than some key
1467// handle: the master key arrives and leaves the host side as a 32-byte array.
1468//
1469// The minimal host-side implementation is to forward these two traits straight
1470// to the platform key store API, **with no decisions of its own**: any decision
1471// belongs in the core layer.
1472
1473/// Source of the device identity key pair: forwarded by the host side to the
1474/// platform hardware key store.
1475///
1476/// Corresponds to the core [`qualia2fa_core::flow::DeviceKeys`]. An
1477/// implementation should forward directly to the platform layer, **with no
1478/// decisions of its own**.
1479///
1480/// # Contract the implementation must satisfy
1481///
1482/// - **`get_or_create` must be idempotent for the same `label`.** A label names
1483/// one key pair for the lifetime of the device.
1484/// - **A key must be persisted immediately**, not buffered until the end of a
1485/// session: a signing call may arrive in a later process start.
1486/// - **`sign` receives the raw message, not a digest.** The host side must not
1487/// hash it first: the core verifies with the standard ECDSA hash mode, so
1488/// hashing twice makes verification report `DeviceSignatureInvalid` while the
1489/// key and the message are both correct.
1490#[uniffi::export(with_foreign)]
1491pub trait FfiDeviceKeys: Send + Sync {
1492 /// Returns the public key for `label`, creating it when it does not exist.
1493 /// SEC1 uncompressed encoding, 65 bytes.
1494 fn get_or_create(&self, label: String) -> Result<Vec<u8>, FfiError>;
1495
1496 /// Signs the **raw message** with the private key for `label`. Returns the
1497 /// 64-byte raw `r‖s`, not DER.
1498 fn sign(&self, label: String, message: Vec<u8>) -> Result<Vec<u8>, FfiError>;
1499}
1500
1501/// Where the master key is persisted: forwarded by the host side to the
1502/// platform hardware key store.
1503///
1504/// Corresponds to the core [`qualia2fa_core::flow::SecureStore`].
1505///
1506/// # `load` returning `None` and returning `Err` are two different things
1507///
1508/// - `Ok(None)`: this device was **never registered**. The user interface
1509/// should guide the user to register or log in.
1510/// - `Err(..)`: the device was registered, but the key cannot be read now
1511/// (hardware reset, key deleted, different user account). The user interface
1512/// must **warn that data may be lost** and must never guide the user to
1513/// register again, since that would overwrite the old data with a new master
1514/// key and make the existing entries permanently unrecoverable.
1515///
1516/// Collapsing these two cases into one `None` is the easiest mistake at this
1517/// interface, and the user interface above cannot tell them apart.
1518#[uniffi::export(with_foreign)]
1519pub trait FfiSecureStore: Send + Sync {
1520 /// Reads the master key, 32 bytes. Returns `None` when the device was never
1521 /// registered.
1522 fn load_master_key(&self) -> Result<Option<Vec<u8>>, FfiError>;
1523
1524 /// Writes the master key (32 bytes), overwriting any existing value.
1525 fn store_master_key(&self, key: Vec<u8>) -> Result<(), FfiError>;
1526
1527 /// Deletes the master key. Calling it on a device that was never registered
1528 /// must not report an error.
1529 fn delete_master_key(&self) -> Result<(), FfiError>;
1530
1531 /// Whether this store is hardware backed (TPM / Secure Enclave / Android
1532 /// Keystore).
1533 ///
1534 /// **Must be reported truthfully.** With no hardware available it returns
1535 /// `false` and the user interface asks for consent before continuing;
1536 /// claiming otherwise would tell the user that a software file is hardware
1537 /// protected.
1538 fn is_hardware_backed(&self) -> bool;
1539}
1540
1541/// Forwarding implementation of the core `DeviceKeys`: bridges trait calls to
1542/// host-side callbacks.
1543struct ForeignDeviceKeys(Arc<dyn FfiDeviceKeys>);
1544
1545impl qualia2fa_core::flow::DeviceKeys for ForeignDeviceKeys {
1546 fn get_or_create(&mut self, label: &str) -> Result<DevicePublicKey, Error> {
1547 let bytes = self
1548 .0
1549 .get_or_create(label.to_owned())
1550 .map_err(ffi_to_core_error)?;
1551 Ok(DevicePublicKey::from_sec1_bytes(&bytes)?)
1552 }
1553
1554 fn sign(&mut self, label: &str, message: &[u8]) -> Result<DeviceSignature, Error> {
1555 let bytes = self
1556 .0
1557 .sign(label.to_owned(), message.to_vec())
1558 .map_err(ffi_to_core_error)?;
1559 Ok(DeviceSignature::from_bytes(&bytes)?)
1560 }
1561}
1562
1563/// Forwarding implementation of the core `SecureStore`: bridges trait calls to
1564/// host-side callbacks.
1565struct ForeignSecureStore(Arc<dyn FfiSecureStore>);
1566
1567impl qualia2fa_core::flow::SecureStore for ForeignSecureStore {
1568 fn load_master_key(&self) -> Result<Option<MasterKey>, Error> {
1569 let bytes = self.0.load_master_key().map_err(ffi_to_core_error)?;
1570 bytes
1571 .map(|bytes| flow::master_key_from_bytes(bytes.into()))
1572 .transpose()
1573 }
1574
1575 fn store_master_key(&mut self, key: &MasterKey) -> Result<(), Error> {
1576 self.0
1577 .store_master_key(flow::master_key_to_bytes(key).expose_secret().to_vec())
1578 .map_err(ffi_to_core_error)
1579 }
1580
1581 fn delete_master_key(&mut self) -> Result<(), Error> {
1582 self.0.delete_master_key().map_err(ffi_to_core_error)
1583 }
1584
1585 fn is_hardware_backed(&self) -> bool {
1586 self.0.is_hardware_backed()
1587 }
1588}
1589
1590/// Converts an [`FfiError`] returned by a host-side callback back into a core
1591/// error.
1592///
1593/// The errors appearing in these callbacks **are core errors already**: the host
1594/// side passes them back (for example, a platform key store reporting unavailable
1595/// hardware, which corresponds to the core [`Error::DeviceKeyMissing`]), and the
1596/// error handling in `flow` branches on that reason. [`FfiError::Core`] is mapped
1597/// back to a core variant through `kind`; what cannot be mapped back — the
1598/// boundary's own [`FfiError::InputTooLong`] and any kind added later — degrades to
1599/// [`Error::DeviceKeyMissing`].
1600fn ffi_to_core_error(source: FfiError) -> Error {
1601 match source {
1602 FfiError::Core { detail } => kind_to_core_error(&detail.kind),
1603 // None of the boundary's own errors can arrive from a host callback: the code
1604 // channel is called from the host, not into it, and an identifier or a conflict
1605 // named by the host is checked where it is used. They degrade the same way as any
1606 // other error that has no core equivalent.
1607 FfiError::InputTooLong { .. }
1608 | FfiError::InputEmpty { .. }
1609 | FfiError::WrongCodeKind { .. }
1610 | FfiError::Qr { .. }
1611 | FfiError::Lan { .. }
1612 | FfiError::InvalidDeviceId { .. }
1613 | FfiError::AuthBound { .. }
1614 | FfiError::UnknownConflict { .. } => Error::DeviceKeyMissing,
1615 }
1616}
1617
1618/// Maps a stable error kind back to a core error variant.
1619///
1620/// **Only the device-identity-related ones**: this function is used on the
1621/// callback-crossing paths, where the set of possible core errors is limited
1622/// (missing key, mismatched identity, failed store write). Everything else
1623/// degrades to [`Error::DeviceKeyMissing`] rather than being forced into a
1624/// variant whose meaning does not fit: a wrong classification is worse than a
1625/// coarse one.
1626fn kind_to_core_error(kind: &str) -> Error {
1627 match kind {
1628 "device_key_missing" => Error::DeviceKeyMissing,
1629 "device_signature_invalid" => Error::Device(qualia_dik::Error::DeviceSignatureInvalid {
1630 reason: "signing failed on the client side",
1631 }),
1632 "secure_store_write_failed" => Error::SecureStoreWriteFailed {
1633 reason: "the client key store refused the write",
1634 },
1635 _ => Error::DeviceKeyMissing,
1636 }
1637}
1638
1639/// The device identity key store of the current platform, exported to the host
1640/// side.
1641///
1642/// # What this object is for
1643///
1644/// [`FfiDeviceKeys`] is a **foreign** trait: the host side implements it. The
1645/// implementation that really exists — create a P-256 key inside the platform
1646/// key store, read its public key, sign with it — lives in `qualia-dik`, so a
1647/// host-side implementation of that trait would only forward. This object is
1648/// that forwarding, written once in Rust: the host side calls the methods below
1649/// and writes no key-store code of its own.
1650///
1651/// # Contract: the byte formats of [`FfiDeviceKeys`]
1652///
1653/// - [`FfiPlatformKeys::device_public_key`] returns the 65-byte SEC1
1654/// uncompressed point `0x04 || X(32) || Y(32)`, the encoding
1655/// [`FfiDeviceKeys::get_or_create`] has to return.
1656/// - [`FfiPlatformKeys::sign`] takes the **raw message** and returns the 64-byte
1657/// raw `r‖s`, the convention [`FfiDeviceKeys::sign`] has to follow.
1658///
1659/// # The backend is reported as it is
1660///
1661/// [`FfiPlatformKeys::backend`] and [`FfiPlatformKeys::is_hardware_backed`]
1662/// report the backend **actually in use**. Opening prefers the TPM and falls back
1663/// to the operating system software key store only when there is no TPM, and the
1664/// fallback stays visible: such a machine reports `"software"` and `false`.
1665/// Reporting a software key as hardware-protected would tell the user that a
1666/// private key which can be copied off the disk is held by hardware.
1667///
1668/// # No signer is kept between calls
1669///
1670/// `qualia-dik` hands out a signer as `Box<dyn DeviceSigner>`, and that trait is
1671/// neither `Send` nor `Sync`: a platform key handle is not thread safe, and the
1672/// library's Windows backend documents the intended pattern as one handle per
1673/// thread rather than one handle shared between threads. A UniFFI object must be
1674/// `Send + Sync`, so no handle is stored here. Every method opens the identity
1675/// under this object's label again, which is the pattern the platform backend
1676/// asks for; the label itself is created once, by [`FfiPlatformKeys::open`].
1677///
1678/// # This type does not implement `Debug`
1679///
1680/// Deriving it would delegate to the key store's own `Debug`, whose output is an
1681/// implementation detail rather than part of this interface, and the object sits
1682/// next to platform key material. Nothing in this crate needs it.
1683///
1684/// # Current state
1685///
1686/// Windows is the only platform with a backend implementation. On every other
1687/// platform [`FfiPlatformKeys::open`] fails with an error naming that platform;
1688/// there is **no** fallback to a software key.
1689#[derive(uniffi::Object)]
1690pub struct FfiPlatformKeys {
1691 /// Key store of the current platform, opened on the best available backend.
1692 store: PlatformKeyStore,
1693 /// Label of the device identity this object manages.
1694 label: String,
1695}
1696
1697#[uniffi::export]
1698impl FfiPlatformKeys {
1699 /// Opens the best available platform backend and the identity stored under
1700 /// `label`, creating that identity when it does not exist yet.
1701 ///
1702 /// "Best available" is the platform's choice, not this layer's: on Windows
1703 /// the TPM 2.0 provider is preferred and the software key store is used only
1704 /// when no TPM is available. Which one was chosen is reported by
1705 /// [`FfiPlatformKeys::backend`], so the host side can tell the user before
1706 /// relying on the identity.
1707 ///
1708 /// # One label is one identity for the lifetime of the device
1709 ///
1710 /// The label is the only handle the platform key store has, so opening the
1711 /// same label again returns the same identity and never creates a second key.
1712 ///
1713 /// # The signer handle returned by the key store is dropped
1714 ///
1715 /// Creating the identity is what this call is for; see [`FfiPlatformKeys`]
1716 /// for why the platform handle is not kept between calls.
1717 ///
1718 /// # Errors
1719 ///
1720 /// [`FfiError::InputTooLong`] when the label is longer than the boundary
1721 /// limit; otherwise a [`FfiError::Core`] error: this machine has no usable
1722 /// platform key store, this platform has no backend yet, the platform rejects
1723 /// the label, or the platform refuses the operation. The key store failures
1724 /// carry the kind `device_error`.
1725 #[uniffi::constructor]
1726 pub fn open(label: String) -> Result<Arc<Self>, FfiError> {
1727 check_len("label", &label, MAX_LABEL_CHARS)?;
1728 let store = open_platform_key_store()?;
1729 store.open_or_create(&label).map_err(platform_error)?;
1730 Ok(Arc::new(Self { store, label }))
1731 }
1732
1733 /// The SEC1 uncompressed public key of the identity under this object's
1734 /// label: 65 bytes, `0x04 || X(32) || Y(32)`.
1735 ///
1736 /// The same key [`FfiDeviceKeys::get_or_create`] has to return for that
1737 /// label, because both name one platform identity. A public key is not a
1738 /// secret: it is propagated to peer devices and shown in QR codes.
1739 ///
1740 /// # Errors
1741 ///
1742 /// A [`FfiError::Core`] error when the identity cannot be opened: it was
1743 /// deleted, this is a different user account, or no key store is available.
1744 pub fn device_public_key(&self) -> Result<Vec<u8>, FfiError> {
1745 let signer = self.signer()?;
1746 Ok(signer.public_key().as_sec1_bytes().to_vec())
1747 }
1748
1749 /// The fingerprint of this device's identity: 64 hexadecimal characters.
1750 ///
1751 /// It is the identifier a sync payload carries as its sender, the value the channel
1752 /// reports for this device (see [`FfiTransport::local_device`]), and the string a
1753 /// pairing screen shows next to a requester. All three are the **same value by
1754 /// construction**: the fingerprint is derived from the public key, so two devices that
1755 /// show the same string really do hold the same identity, with no registry to keep in
1756 /// step and nothing to forge.
1757 ///
1758 /// # Errors
1759 ///
1760 /// A [`FfiError::Core`] error when the identity cannot be opened: it was deleted, this
1761 /// is a different user account, or no key store is available.
1762 pub fn device_id(&self) -> Result<String, FfiError> {
1763 Ok(self.signer()?.public_key().device_id().to_hex())
1764 }
1765
1766 /// Signs the **raw message** with the identity under this object's label and
1767 /// returns the 64-byte raw `r‖s`.
1768 ///
1769 /// # Do not hash the message first
1770 ///
1771 /// The platform hashes with SHA-256 internally and the core verifies with the
1772 /// standard ECDSA hash mode, so a digest passed in here would be hashed a
1773 /// second time and every verification would report `device_signature_invalid`
1774 /// while both the key and the message are correct.
1775 ///
1776 /// # The return value is not DER
1777 ///
1778 /// It is the fixed 64-byte `r‖s` layout the protocol uses. DER is variable
1779 /// length (68 to 72 bytes, depending on leading zero padding), which would
1780 /// reveal the high bytes of `r` and `s` and give one mathematical signature
1781 /// several valid encodings.
1782 ///
1783 /// # Errors
1784 ///
1785 /// A [`FfiError::Core`] error (`device_error`) when the platform refuses to
1786 /// sign — a cancelled authentication prompt, for example — or the identity
1787 /// cannot be opened.
1788 pub fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>, FfiError> {
1789 let signer = self.signer()?;
1790 let signature = signer.sign(&message).map_err(platform_error)?;
1791 Ok(signature.as_bytes().to_vec())
1792 }
1793
1794 /// The backend that holds this identity, as a stable short name: `"tpm"`,
1795 /// `"secure-enclave"`, `"android-keystore"`, `"software"` or
1796 /// `"unavailable"`.
1797 ///
1798 /// The value comes from the key store rather than from a signer, so asking
1799 /// for it opens no key handle. It is reported **truthfully** and may be
1800 /// weaker than what the platform could provide: a Windows machine without a
1801 /// TPM reports `"software"`, which means the private key is an ordinary
1802 /// operating system key store entry. An identity that opened successfully
1803 /// reports `"tpm"` or `"software"` today, since Windows is the only platform
1804 /// with a backend.
1805 #[must_use]
1806 pub fn backend(&self) -> String {
1807 self.store.backend().as_str().to_owned()
1808 }
1809
1810 /// Whether the private key of this identity cannot leave hardware.
1811 ///
1812 /// Consistent with [`FfiPlatformKeys::backend`] by construction: `true`
1813 /// exactly when that backend is a hardware one. With `"software"` the key can
1814 /// be taken by an attacker who has administrator rights or disk access, so
1815 /// the host side has to tell the user before a critical operation.
1816 #[must_use]
1817 pub fn is_hardware_backed(&self) -> bool {
1818 self.store.backend().is_hardware_backed()
1819 }
1820}
1821
1822/// Helpers of [`FfiPlatformKeys`] that are **not** exported.
1823///
1824/// A separate `impl` block on purpose: `#[uniffi::export]` processes every method
1825/// of the block it is attached to, including private ones, and it cannot generate
1826/// cross-boundary code for a Rust-only type such as `Box<dyn DeviceSigner>`.
1827impl FfiPlatformKeys {
1828 /// Opens the identity stored under this object's label.
1829 ///
1830 /// Written in fully qualified form rather than as a method call: the Windows
1831 /// backend has an **inherent** `open(preferred: WindowsProvider)`
1832 /// constructor, and an inherent method of that name wins method resolution
1833 /// and then fails on the argument type. Fully qualified syntax names the
1834 /// trait method on every platform.
1835 fn signer(&self) -> Result<Box<dyn DeviceSigner>, FfiError> {
1836 DeviceKeyStore::open(&self.store, &self.label).map_err(platform_error)
1837 }
1838}
1839
1840/// Opens the platform key store on the best backend this machine has.
1841///
1842/// # Why one `cfg` appears here
1843///
1844/// The constructors differ **on purpose**: opening a key store can fail where there is one
1845/// to open — Windows may have no TPM, a key store held by another process or a policy that
1846/// forbids it, and Android may be reached without the Java VM this library needs — and those
1847/// return a `Result`. The placeholder compiled on the remaining platforms cannot fail and
1848/// returns the value directly. `qualia-dik` documents that callers write the call once per
1849/// platform instead of hiding the difference, so this function is the only place in this
1850/// crate where a platform `cfg` appears; no method carries one.
1851///
1852/// On a platform whose backend is not implemented, the store returned here fails
1853/// every operation with an error naming that platform, and **nothing falls back
1854/// to a software key**.
1855fn open_platform_key_store() -> Result<PlatformKeyStore, FfiError> {
1856 #[cfg(any(target_os = "windows", target_os = "android"))]
1857 {
1858 PlatformKeyStore::open_best_available().map_err(platform_error)
1859 }
1860 #[cfg(not(any(target_os = "windows", target_os = "android")))]
1861 {
1862 Ok(PlatformKeyStore::open_best_available())
1863 }
1864}
1865
1866/// Converts an error of the device identity library into an [`FfiError`].
1867///
1868/// The core wraps that error in its own `Error::Device` variant (`#[from]`), so
1869/// it reaches the host side with the stable `kind` that [`core_error_kind`]
1870/// already assigns to it: `device_public_key_invalid`, `device_signature_invalid`
1871/// and the master key storage failures keep the specific kinds that function
1872/// lists, and every other key store failure is `device_error`. The library's
1873/// messages are safe to pass on: each `reason` field is a fixed `&'static str`
1874/// supplied by the library, never key material.
1875fn platform_error(source: qualia_dik::Error) -> FfiError {
1876 Error::from(source).into()
1877}
1878
1879// ── The master key: the two forms the platform key store offers ──────────
1880//
1881// The application's master key has two storage forms, and `qualia-dik` models them as two
1882// interfaces reached through two constructors. This section mirrors that shape across the
1883// boundary instead of inventing a mode argument: the host side chooses a form by opening one
1884// object or the other. A review of this section asks the same question the platform key
1885// store section asks: **has any judgement been copied in from the library?**
1886
1887/// The application's master key held **directly** by the platform key store.
1888///
1889/// The store keeps the material itself (a CNG key on Windows, a Keystore entry on Android)
1890/// and the host side persists nothing of its own for it. Reading it back returns the same
1891/// 32 bytes.
1892///
1893/// # The two forms, and how the host side picks one
1894///
1895/// [`FfiWrappedMasterKeyStore`] is the other form. There is no flag and no mode argument
1896/// anywhere in this section: a caller that wants the wrapped form reaches for the other
1897/// object, so a platform that implements only one form cannot silently answer for both.
1898/// [`FfiDirectMasterKeyStore::backend`] reports what actually answered.
1899///
1900/// # The master key crosses this boundary as bytes, and as nothing else
1901///
1902/// [`FfiDirectMasterKeyStore::store`] takes 32 bytes, [`FfiDirectMasterKeyStore::load`]
1903/// returns 32 bytes. The material is never formatted, never logged and never part of an
1904/// error message; the host side should drop its copy as soon as it has handed the bytes over
1905/// or built an [`FfiVault`] from them.
1906///
1907/// # Current state
1908///
1909/// Windows and Android have a backend. On every other platform
1910/// [`FfiDirectMasterKeyStore::open`] still succeeds — the object is usable and reports
1911/// `"unavailable"` — and every operation fails with the kind `unsupported_platform`. Nothing
1912/// falls back to an in-process map.
1913#[derive(uniffi::Object)]
1914pub struct FfiDirectMasterKeyStore {
1915 /// The store of this form, held as the interface rather than as the platform's concrete
1916 /// type: UniFFI objects cannot be generic, and the tests drive a software backend
1917 /// through exactly the methods production calls.
1918 store: Box<dyn DirectMasterKeyStore + Send + Sync>,
1919}
1920
1921#[uniffi::export]
1922impl FfiDirectMasterKeyStore {
1923 /// Opens the direct master key store of the current platform.
1924 ///
1925 /// # Errors
1926 ///
1927 /// A [`FfiError::Core`] error with the kind `hardware_unavailable` when no provider on
1928 /// this machine can hold master key material. A platform whose backend is not
1929 /// implemented yet still opens: the failures arrive from the operations below, with the
1930 /// kind `unsupported_platform`, so the host side can name the platform instead of
1931 /// reporting a machine that is out of order.
1932 #[uniffi::constructor]
1933 pub fn open() -> Result<Arc<Self>, FfiError> {
1934 let store = open_platform_direct_master_key_store()?;
1935 Ok(Self::with_store(Box::new(store)))
1936 }
1937
1938 /// The platform key store that holds the material, as a stable short name: `"tpm"`,
1939 /// `"android-keystore"`, `"software"`, `"secure-enclave"` or `"unavailable"`.
1940 ///
1941 /// Reported **truthfully**, exactly as [`FfiPlatformKeys::backend`] does: asking for it
1942 /// opens no key handle, and a machine without a TPM says `"software"` rather than
1943 /// pretending. The host side can use it to tell the user before relying on the store.
1944 #[must_use]
1945 pub fn backend(&self) -> String {
1946 self.store.backend().as_str().to_owned()
1947 }
1948
1949 /// Whether the material cannot leave hardware.
1950 ///
1951 /// Consistent with [`FfiDirectMasterKeyStore::backend`] by construction: `true` exactly
1952 /// when that backend is a hardware one.
1953 #[must_use]
1954 pub fn is_hardware_backed(&self) -> bool {
1955 self.store.backend().is_hardware_backed()
1956 }
1957
1958 /// Stores the 32-byte `key` under `label`, replacing what was stored there.
1959 ///
1960 /// Replacing is deliberate: a recovery or a re-pairing legitimately replaces the master
1961 /// key, and refusing would leave the host side unable to follow its own vault files.
1962 ///
1963 /// # Errors
1964 ///
1965 /// [`FfiError::InputTooLong`] for a label over the boundary limit; the core's
1966 /// `master_key_wrong_length` when `key` is not 32 bytes; `key_not_exportable` when the
1967 /// platform takes the material but will not hand it back, which is the case that wants
1968 /// [`FfiWrappedMasterKeyStore`]; otherwise a key store failure, reported as
1969 /// `hardware_unavailable`, `unsupported_platform` or `device_error`.
1970 pub fn store(&self, label: String, key: Vec<u8>) -> Result<(), FfiError> {
1971 check_len("label", &label, MAX_LABEL_CHARS)?;
1972 let key = dik_master_key(key)?;
1973 DirectMasterKeyStore::store(self.store.as_ref(), &label, &key).map_err(platform_error)
1974 }
1975
1976 /// Reads back the master key stored under `label`: the 32 bytes
1977 /// [`FfiDirectMasterKeyStore::store`] was given.
1978 ///
1979 /// **Never creates or replaces anything**: a label that holds nothing fails instead.
1980 ///
1981 /// # Errors
1982 ///
1983 /// [`FfiError::InputTooLong`] for an oversized label, and otherwise a key store failure:
1984 /// a label that holds nothing is `device_error` today, an unusable platform is
1985 /// `unsupported_platform`, and `invalid_master_key` reports a store that returned
1986 /// something which is not master key material.
1987 pub fn load(&self, label: String) -> Result<Vec<u8>, FfiError> {
1988 check_len("label", &label, MAX_LABEL_CHARS)?;
1989 let key =
1990 DirectMasterKeyStore::load(self.store.as_ref(), &label).map_err(platform_error)?;
1991 Ok(key.as_bytes().to_vec())
1992 }
1993
1994 /// Deletes the master key stored under `label`.
1995 ///
1996 /// # Warning
1997 ///
1998 /// Every vault of that account becomes unreadable on this device unless the key exists
1999 /// elsewhere — a recovery key, another device. **The host side has to ask the user
2000 /// first.**
2001 ///
2002 /// # Errors
2003 ///
2004 /// [`FfiError::InputTooLong`] for an oversized label, and otherwise the same key store
2005 /// failures as [`FfiDirectMasterKeyStore::load`].
2006 pub fn delete(&self, label: String) -> Result<(), FfiError> {
2007 check_len("label", &label, MAX_LABEL_CHARS)?;
2008 DirectMasterKeyStore::delete(self.store.as_ref(), &label).map_err(platform_error)
2009 }
2010}
2011
2012/// The application's master key held **wrapped** by a key-encryption key inside the platform
2013/// key store.
2014///
2015/// The store generates the key-encryption key (a non-exportable CNG key on Windows, an
2016/// Android Keystore key on Android), keeps it where nothing can read it out, and wraps the
2017/// master key with it. [`FfiWrappedMasterKeyStore::store`] hands back the **wrapped bytes**
2018/// for the host side to persist, and [`FfiWrappedMasterKeyStore::load`] takes them back and
2019/// returns the master key.
2020///
2021/// # Nothing here wraps, unwraps or hands out a key-encryption key
2022///
2023/// Wrapping is the internal design of this form, and the library keeps it that way: no
2024/// operation of this object encrypts, decrypts or returns the key-encryption key, and none
2025/// is added here. The host side stores a master key and receives bytes to keep; what those
2026/// bytes mean is the platform's business.
2027///
2028/// # The bytes belong to one store entry
2029///
2030/// Bytes produced by another store entry, or damaged in storage, **fail** rather than unwrap
2031/// into a key that merely differs. That is what makes them safe to persist next to the vault
2032/// file they belong to. The wrapped form is not secret the way the master key is: whoever
2033/// copies the bytes still needs the platform key, which is not in them.
2034///
2035/// # Current state
2036///
2037/// Same as [`FfiDirectMasterKeyStore`]: Windows and Android have a backend, and every other
2038/// platform reports `unsupported_platform` from every operation.
2039#[derive(uniffi::Object)]
2040pub struct FfiWrappedMasterKeyStore {
2041 /// The store of this form, held as the interface for the reason given on
2042 /// [`FfiDirectMasterKeyStore`].
2043 store: Box<dyn WrappedMasterKeyStore + Send + Sync>,
2044}
2045
2046#[uniffi::export]
2047impl FfiWrappedMasterKeyStore {
2048 /// Opens the wrapped master key store of the current platform.
2049 ///
2050 /// # Errors
2051 ///
2052 /// A [`FfiError::Core`] error with the kind `hardware_unavailable` when this machine has
2053 /// no provider that can create a key-encryption key; see
2054 /// [`FfiDirectMasterKeyStore::open`] for why an unimplemented platform opens anyway.
2055 #[uniffi::constructor]
2056 pub fn open() -> Result<Arc<Self>, FfiError> {
2057 let store = open_platform_wrapped_master_key_store()?;
2058 Ok(Self::with_store(Box::new(store)))
2059 }
2060
2061 /// The platform key store that holds the key-encryption key, as a stable short name:
2062 /// `"tpm"`, `"android-keystore"`, `"software"`, `"secure-enclave"` or `"unavailable"`.
2063 ///
2064 /// Reported truthfully, like [`FfiDirectMasterKeyStore::backend`]; this is the value the
2065 /// host side tells the user about before it relies on the wrapped form.
2066 #[must_use]
2067 pub fn backend(&self) -> String {
2068 self.store.backend().as_str().to_owned()
2069 }
2070
2071 /// Whether the key-encryption key cannot leave hardware.
2072 ///
2073 /// Consistent with [`FfiWrappedMasterKeyStore::backend`] by construction: `true` exactly
2074 /// when that backend is a hardware one.
2075 #[must_use]
2076 pub fn is_hardware_backed(&self) -> bool {
2077 self.store.backend().is_hardware_backed()
2078 }
2079
2080 /// Wraps the 32-byte `key` under `label` and returns the **bytes the host side has to
2081 /// persist**.
2082 ///
2083 /// The bytes are opaque: their length and contents belong to the platform, and only the
2084 /// same store entry can turn them back into the master key. Persist them next to the
2085 /// vault file they belong to; losing them means the master key has to be recovered
2086 /// another way, and deleting the label's entry makes them permanently unusable.
2087 ///
2088 /// Wrapping under a label that already exists reuses the key-encryption key that is
2089 /// already there, so bytes persisted earlier keep unwrapping.
2090 ///
2091 /// # Errors
2092 ///
2093 /// [`FfiError::InputTooLong`] for an oversized label; the core's
2094 /// `master_key_wrong_length` when `key` is not 32 bytes; otherwise a key store failure,
2095 /// reported as `unsupported_platform` or `device_error` — a platform that cannot create
2096 /// a key-encryption key included.
2097 pub fn store(&self, label: String, key: Vec<u8>) -> Result<Vec<u8>, FfiError> {
2098 check_len("label", &label, MAX_LABEL_CHARS)?;
2099 let key = dik_master_key(key)?;
2100 let wrapped = WrappedMasterKeyStore::store(self.store.as_ref(), &label, &key)
2101 .map_err(platform_error)?;
2102 Ok(wrapped.into_bytes())
2103 }
2104
2105 /// Unwraps `wrapped` — the bytes [`FfiWrappedMasterKeyStore::store`] handed back for
2106 /// `label` — and returns the master key.
2107 ///
2108 /// # Errors
2109 ///
2110 /// `invalid_wrapped_key` when the bytes do not unwrap: a damaged blob, or one produced
2111 /// by another store entry or another device. **The method never returns bytes it could
2112 /// not authenticate**, so a failure here means "these bytes are unusable", not "here is
2113 /// a different key". A label that holds no key-encryption key at all is `device_error`
2114 /// today.
2115 pub fn load(&self, label: String, wrapped: Vec<u8>) -> Result<Vec<u8>, FfiError> {
2116 check_len("label", &label, MAX_LABEL_CHARS)?;
2117 let wrapped = WrappedMasterKey::from_bytes(wrapped);
2118 let key = WrappedMasterKeyStore::load(self.store.as_ref(), &label, &wrapped)
2119 .map_err(platform_error)?;
2120 Ok(key.as_bytes().to_vec())
2121 }
2122
2123 /// Deletes the key-encryption key stored under `label`.
2124 ///
2125 /// # Warning
2126 ///
2127 /// Every blob wrapped with that key becomes permanently unusable, which is how a device
2128 /// forgets a master key. **The host side has to ask the user first**, and it has to be
2129 /// prepared for the persisted bytes to be worthless afterwards.
2130 ///
2131 /// # Errors
2132 ///
2133 /// [`FfiError::InputTooLong`] for an oversized label, and otherwise the same key store
2134 /// failures as [`FfiWrappedMasterKeyStore::load`].
2135 pub fn delete(&self, label: String) -> Result<(), FfiError> {
2136 check_len("label", &label, MAX_LABEL_CHARS)?;
2137 WrappedMasterKeyStore::delete(self.store.as_ref(), &label).map_err(platform_error)
2138 }
2139}
2140
2141/// Helpers of the master key store objects that are **not** exported.
2142///
2143/// Separate `impl` blocks on purpose: `#[uniffi::export]` lowers every method of the block
2144/// it is attached to, and it cannot generate cross-boundary code for a
2145/// `Box<dyn DirectMasterKeyStore>` or a `Box<dyn WrappedMasterKeyStore>`.
2146impl FfiDirectMasterKeyStore {
2147 /// Wraps a store in the exported object.
2148 fn with_store(store: Box<dyn DirectMasterKeyStore + Send + Sync>) -> Arc<Self> {
2149 Arc::new(Self { store })
2150 }
2151}
2152
2153impl FfiWrappedMasterKeyStore {
2154 /// Wraps a store in the exported object.
2155 fn with_store(store: Box<dyn WrappedMasterKeyStore + Send + Sync>) -> Arc<Self> {
2156 Arc::new(Self { store })
2157 }
2158}
2159
2160/// What the platform's auth-bound key store can do, asked before offering the path.
2161#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
2162pub enum FfiAuthAvailability {
2163 /// The platform can create and use an authentication-bound key.
2164 Available {
2165 /// Stable backend name (`windows-webauthn`, `android-keystore`, ...).
2166 backend: String,
2167 /// Whether the platform reported hardware protection. Never guessed.
2168 hardware_backed: bool,
2169 },
2170 /// The mechanism exists, but this machine or user cannot use it right now.
2171 Unavailable {
2172 /// Why the platform refused; fixed text from the backend.
2173 reason: String,
2174 },
2175 /// This build has no backend for the platform.
2176 Unsupported {
2177 /// Why no backend exists; fixed text from the fallback.
2178 reason: String,
2179 },
2180}
2181
2182/// The platform auth-bound key store used by `master.key.bio`.
2183///
2184/// The master key crosses this boundary as a `Vec<u8>` only where the host has no other
2185/// option: [`FfiVault::wrap_master_for_auth_bound`] keeps the key inside the vault, and the
2186/// login path needs the unwrapped key to open the vault at all. The bytes are erased on the
2187/// Rust side after use; the host-side copy is the caller's responsibility.
2188#[derive(uniffi::Object)]
2189pub struct FfiAuthBoundKeyStore {
2190 /// The platform store, held as the interface so the host sees one object on every
2191 /// platform, including the unsupported fallback.
2192 store: Box<dyn bioauth::AuthBoundKeyStore>,
2193}
2194
2195#[uniffi::export]
2196impl FfiAuthBoundKeyStore {
2197 /// Builds a store over a host-supplied platform sealer.
2198 ///
2199 /// `backend` names the mechanism the host uses: `android-keystore` or
2200 /// `apple-secure-enclave`. It is written into every blob, so it must describe what the
2201 /// host actually did; a blob records it and a mismatch is refused on the way in.
2202 ///
2203 /// # Errors
2204 ///
2205 /// `bioauth_unknown_backend` when the name is not one this build knows.
2206 #[uniffi::constructor]
2207 pub fn with_host(
2208 sealer: Arc<dyn FfiPlatformSealer>,
2209 backend: String,
2210 ) -> Result<Arc<Self>, FfiError> {
2211 let backend = backend_from_name(&backend)?;
2212 Ok(Arc::new(Self {
2213 store: Box::new(bioauth::DelegatingKeyStore::new(Arc::new(HostSealer {
2214 host: sealer,
2215 backend,
2216 }))),
2217 }))
2218 }
2219
2220 /// Opens the auth-bound key store of the current platform.
2221 ///
2222 /// Opening never fails: a platform without a backend returns an object whose
2223 /// [`FfiAuthBoundKeyStore::availability`] is `unsupported`, which lets the host show
2224 /// the truth instead of a "could not open" error.
2225 #[uniffi::constructor]
2226 pub fn open() -> Arc<Self> {
2227 Arc::new(Self {
2228 store: bioauth::platform::open_best_available(),
2229 })
2230 }
2231
2232 /// Reports what this store can do, without prompting.
2233 #[must_use]
2234 pub fn availability(&self) -> FfiAuthAvailability {
2235 match self.store.availability() {
2236 bioauth::Availability::Available {
2237 backend,
2238 hardware_backed,
2239 } => FfiAuthAvailability::Available {
2240 backend: backend.name().to_owned(),
2241 hardware_backed,
2242 },
2243 bioauth::Availability::Unavailable { reason } => FfiAuthAvailability::Unavailable {
2244 reason: reason.to_owned(),
2245 },
2246 bioauth::Availability::Unsupported { reason } => FfiAuthAvailability::Unsupported {
2247 reason: reason.to_owned(),
2248 },
2249 }
2250 }
2251
2252 /// Creates the platform credential or key, prompting if the platform requires it.
2253 ///
2254 /// # Errors
2255 ///
2256 /// [`FfiError::AuthBound`] with the backend's kind; `bioauth_authentication_failed`
2257 /// when the user cancelled, `bioauth_unavailable` when the platform is not usable.
2258 pub fn create(&self) -> Result<(), FfiError> {
2259 self.store.create().map_err(auth_bound_error)
2260 }
2261
2262 /// Wraps `master_key` and returns the complete blob for `master.key.bio`.
2263 ///
2264 /// # Errors
2265 ///
2266 /// [`FfiError::AuthBound`] for a cancelled gesture or an unavailable platform;
2267 /// `bioauth_master_key_wrong_length` when the key is not 32 bytes.
2268 pub fn wrap(&self, master_key: Vec<u8>) -> Result<Vec<u8>, FfiError> {
2269 let master = bioauth::MasterKey::from_bytes(&master_key).map_err(auth_bound_error)?;
2270 self.store.wrap(&master).map_err(auth_bound_error)
2271 }
2272
2273 /// Authenticates the user and returns the master key from `blob`.
2274 ///
2275 /// # Errors
2276 ///
2277 /// [`FfiError::AuthBound`] with `bioauth_authentication_failed` for a cancelled
2278 /// gesture, `bioauth_wrong_backend` for a blob written by another backend, and
2279 /// `bioauth_wrapped_payload_invalid` for a damaged blob.
2280 pub fn unwrap(&self, blob: Vec<u8>) -> Result<Vec<u8>, FfiError> {
2281 let master = self.store.unwrap(&blob).map_err(auth_bound_error)?;
2282 Ok(master.expose_secret().to_vec())
2283 }
2284
2285 /// Deletes the platform key or credential this store remembers.
2286 pub fn delete(&self) -> Result<(), FfiError> {
2287 self.store.delete().map_err(auth_bound_error)
2288 }
2289
2290 /// Deletes the platform credential named by `blob`.
2291 ///
2292 /// Used when the host turns the second path off after a restart and no longer has the
2293 /// credential id in memory. The blob is checked against this backend before anything is
2294 /// deleted, so a copied Windows blob does not delete an unrelated Android key.
2295 pub fn delete_blob(&self, blob: Vec<u8>) -> Result<(), FfiError> {
2296 self.store.delete_blob(&blob).map_err(auth_bound_error)
2297 }
2298}
2299
2300impl FfiAuthBoundKeyStore {
2301 /// Wraps a store for tests, which need a backend whose answers are fixed.
2302 #[cfg(test)]
2303 fn with_store(store: Box<dyn bioauth::AuthBoundKeyStore>) -> Arc<Self> {
2304 Arc::new(Self { store })
2305 }
2306}
2307
2308/// The platform half of `master.key.bio`, implemented by the host application.
2309///
2310/// Android cannot be served from Rust: an auth-bound key lives in the Android Keystore and
2311/// using it needs a `BiometricPrompt` attached to an Activity's UI thread, which a library
2312/// cannot host. The host therefore supplies the sealing calls, and this crate keeps the
2313/// envelope, the backend identifier and the length checks.
2314///
2315/// Implementations may prompt and may block until the user has authenticated.
2316#[uniffi::export(with_foreign)]
2317pub trait FfiPlatformSealer: Send + Sync {
2318 /// The backend these bytes belong to: `android-keystore` or `apple-secure-enclave`.
2319 fn backend(&self) -> String;
2320
2321 /// Reports whether sealing is possible right now, without prompting.
2322 fn availability(&self) -> FfiAuthAvailability;
2323
2324 /// Seals 32 bytes of key material, prompting if the platform requires it.
2325 fn seal(&self, plaintext: Vec<u8>) -> Result<Vec<u8>, FfiError>;
2326
2327 /// Authenticates the user and returns the plaintext.
2328 ///
2329 /// A cancelled gesture is an error, never a buffer of zeros.
2330 fn unseal(&self, envelope: Vec<u8>) -> Result<Vec<u8>, FfiError>;
2331
2332 /// Deletes the platform key. Idempotent.
2333 fn delete(&self) -> Result<(), FfiError>;
2334}
2335
2336/// Adapts a host [`FfiPlatformSealer`] to the crate's own seam.
2337///
2338/// The adapter is where the two error vocabularies meet: a cancelled gesture has to stay
2339/// `AuthenticationFailed` across the boundary, because the caller shows a different message
2340/// for "the user pressed cancel" than for "the platform is broken".
2341impl core::fmt::Debug for HostSealer {
2342 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2343 f.write_str("HostSealer")
2344 }
2345}
2346
2347struct HostSealer {
2348 /// The host's implementation.
2349 host: Arc<dyn FfiPlatformSealer>,
2350 /// The backend the host declared, checked once when the store is built.
2351 backend: Backend,
2352}
2353
2354/// Maps the boundary error back into the crate's vocabulary.
2355fn host_error(error: FfiError) -> bioauth::Error {
2356 match error.kind() {
2357 "bioauth_authentication_failed" => bioauth::Error::AuthenticationFailed,
2358 "bioauth_wrapped_payload_invalid" => bioauth::Error::WrappedPayloadInvalid {
2359 reason: "the host could not authenticate the envelope",
2360 },
2361 "bioauth_unavailable" => bioauth::Error::Unavailable {
2362 reason: "the platform cannot authenticate right now",
2363 },
2364 _ => bioauth::Error::Platform {
2365 operation: "host platform sealer",
2366 code: 0,
2367 },
2368 }
2369}
2370
2371impl bioauth::PlatformSealer for HostSealer {
2372 fn backend(&self) -> Backend {
2373 self.backend
2374 }
2375
2376 fn availability(&self) -> bioauth::Availability {
2377 match self.host.availability() {
2378 FfiAuthAvailability::Available {
2379 backend: _,
2380 hardware_backed,
2381 } => bioauth::Availability::Available {
2382 backend: self.backend,
2383 hardware_backed,
2384 },
2385 FfiAuthAvailability::Unavailable { reason } => bioauth::Availability::Unavailable {
2386 reason: leak_reason(&reason),
2387 },
2388 FfiAuthAvailability::Unsupported { reason } => bioauth::Availability::Unsupported {
2389 reason: leak_reason(&reason),
2390 },
2391 }
2392 }
2393
2394 fn seal(&self, plaintext: &[u8]) -> bioauth::Result<Vec<u8>> {
2395 self.host
2396 .seal(plaintext.to_vec())
2397 .map_err(host_error)
2398 .and_then(|sealed| {
2399 if sealed.is_empty() {
2400 Err(bioauth::Error::PayloadEmpty)
2401 } else {
2402 Ok(sealed)
2403 }
2404 })
2405 }
2406
2407 fn unseal(&self, envelope: &[u8]) -> bioauth::Result<Vec<u8>> {
2408 self.host.unseal(envelope.to_vec()).map_err(host_error)
2409 }
2410
2411 fn delete(&self) -> bioauth::Result<()> {
2412 self.host.delete().map_err(host_error)
2413 }
2414}
2415
2416/// Interns a host-supplied reason so the crate's `&'static str` reasons can carry it.
2417///
2418/// The crate's reasons are static strings because they are fixed messages; a host reason
2419/// comes from the platform. Leaking is bounded by the number of distinct platform answers,
2420/// which is a handful per process, and the alternative would be to drop the platform's own
2421/// words from the report.
2422fn leak_reason(reason: &str) -> &'static str {
2423 if reason.is_empty() {
2424 return "the platform reported no reason";
2425 }
2426 Box::leak(reason.to_owned().into_boxed_str())
2427}
2428
2429/// Reads the backend a host declares, rejecting a name this build does not know.
2430fn backend_from_name(name: &str) -> Result<Backend, FfiError> {
2431 match name {
2432 "android-keystore" => Ok(Backend::AndroidKeystore),
2433 "apple-secure-enclave" => Ok(Backend::AppleSecureEnclave),
2434 other => Err(FfiError::AuthBound {
2435 kind: "bioauth_unknown_backend".to_owned(),
2436 detail: format!("{other} is not a backend a host may supply"),
2437 }),
2438 }
2439}
2440
2441/// Maps a `qualia_bioauth` error to the FFI error.
2442fn auth_bound_error(error: bioauth::Error) -> FfiError {
2443 use bioauth::Error as E;
2444 let kind = match &error {
2445 E::Malformed { .. } => "bioauth_malformed",
2446 E::UnsupportedVersion { .. } => "bioauth_unsupported_version",
2447 E::UnknownBackend { .. } => "bioauth_unknown_backend",
2448 E::WrongBackend { .. } => "bioauth_wrong_backend",
2449 E::PayloadTooLong { .. } => "bioauth_payload_too_long",
2450 E::PayloadEmpty => "bioauth_payload_empty",
2451 E::MasterKeyWrongLength { .. } => "bioauth_master_key_wrong_length",
2452 E::Unavailable { .. } => "bioauth_unavailable",
2453 E::AuthenticationFailed => "bioauth_authentication_failed",
2454 E::WrappedPayloadInvalid { .. } => "bioauth_wrapped_payload_invalid",
2455 E::Platform { .. } => "bioauth_platform",
2456 E::RandomnessUnavailable => "bioauth_randomness_unavailable",
2457 E::MockAuthenticationFailed => "bioauth_mock_authentication_failed",
2458 _ => "bioauth_unknown",
2459 };
2460 FfiError::AuthBound {
2461 kind: kind.to_owned(),
2462 detail: error.to_string(),
2463 }
2464}
2465
2466/// Length of a password-wrapped master key (`master.key.pw`).
2467///
2468/// The host side writes the file the wrap returns; the length is exposed so that a
2469/// reader can reject a wrong-sized file before calling the unwrap function, which has to
2470/// run Argon2id.
2471#[uniffi::export]
2472#[must_use]
2473pub fn lock_wrap_len() -> u64 {
2474 qualia2fa_core::LOCK_WRAP_LEN as u64
2475}
2476
2477/// Wraps a 32-byte master key with the unlock password.
2478///
2479/// The returned bytes are `master.key.pw`: the complete file, magic and all. The host
2480/// side writes them next to the vault. The master key must be 32 bytes, the same
2481/// boundary [`FfiVault::from_master_key`] uses.
2482///
2483/// # Errors
2484///
2485/// - a master key of the wrong length: `master_key_wrong_length`
2486/// - an empty password: `lock_passphrase_empty`
2487/// - an oversized password: [`FfiError::InputTooLong`]
2488/// - a derivation failure: `lock_kdf_failed`
2489/// - an encryption failure: `lock_encryption_failed`
2490#[uniffi::export]
2491pub fn wrap_master_key_with_password(
2492 master_key: Vec<u8>,
2493 password: String,
2494) -> Result<Vec<u8>, FfiError> {
2495 check_len("password", &password, MAX_PASSPHRASE_CHARS)?;
2496 let master = flow::master_key_from_bytes(master_key.into())?;
2497 let passphrase = SecretString::from(password);
2498 qualia2fa_core::lock::wrap(&master, &passphrase, &KdfParams::recommended()).map_err(Into::into)
2499}
2500
2501/// Opens a password-wrapped master key and returns the 32-byte key.
2502///
2503/// The caller hands the returned bytes to an in-memory [`FfiSecureStore`] and then calls
2504/// [`FfiVault::open`]. The bytes are the master key itself, so the host side must wipe
2505/// its copy as soon as the vault object exists.
2506///
2507/// # Errors
2508///
2509/// - a malformed file: `lock_file_malformed`
2510/// - an unsupported version or algorithm: `unsupported_format_version`
2511/// - parameters out of range: `lock_parameters_invalid`
2512/// - an empty password: `lock_passphrase_empty`
2513/// - a wrong password or tampered file: `lock_password_rejected`
2514/// - a derivation failure: `lock_kdf_failed`
2515///
2516/// `lock_password_rejected` merges "wrong password" and "file modified" on purpose; the
2517/// unlock screen shows the same message for both.
2518#[uniffi::export]
2519pub fn unwrap_master_key_with_password(
2520 wrapped: Vec<u8>,
2521 password: String,
2522) -> Result<Vec<u8>, FfiError> {
2523 check_len("password", &password, MAX_PASSPHRASE_CHARS)?;
2524 let passphrase = SecretString::from(password);
2525 let master = qualia2fa_core::lock::unwrap(&wrapped, &passphrase)?;
2526 Ok(flow::master_key_to_bytes(&master).expose_secret().to_vec())
2527}
2528
2529/// Builds the `lock.marker` contents.
2530///
2531/// The marker records that the lock is enabled, which wraps existed when it was written and
2532/// when the migration finished. It contains no secret; its flags are advisory and the
2533/// application must still look at the actual wrap files.
2534///
2535/// # Errors
2536///
2537/// - neither wrap present: `lock_file_malformed`
2538/// - `migrated_at` of zero: `timestamp_zero`
2539#[uniffi::export]
2540pub fn lock_marker(password: bool, biometric: bool, migrated_at: u64) -> Result<Vec<u8>, FfiError> {
2541 Ok(qualia2fa_core::LockMarker::new(password, biometric, migrated_at)?.encode())
2542}
2543
2544/// Builds the `lock.pending` contents.
2545///
2546/// Its presence means a creation, migration or recovery is in progress and the next start
2547/// must finish or roll back. It contains no secret.
2548///
2549/// # Errors
2550///
2551/// A zero `started_at`: `timestamp_zero`.
2552#[uniffi::export]
2553pub fn lock_pending(started_at: u64) -> Result<Vec<u8>, FfiError> {
2554 Ok(qualia2fa_core::LockPending::new(started_at)?.encode())
2555}
2556
2557/// Whether `bytes` are a valid `lock.marker`.
2558///
2559/// A marker that cannot be read is **not** a legacy installation: the caller must fail
2560/// closed instead of opening the vault without a password.
2561#[uniffi::export]
2562#[must_use]
2563pub fn is_lock_marker(bytes: Vec<u8>) -> bool {
2564 qualia2fa_core::LockMarker::parse(&bytes).is_ok()
2565}
2566
2567/// Whether `bytes` are a valid `lock.pending`.
2568#[uniffi::export]
2569#[must_use]
2570pub fn is_lock_pending(bytes: Vec<u8>) -> bool {
2571 qualia2fa_core::LockPending::parse(&bytes).is_ok()
2572}
2573
2574/// Whether `bytes` are a structurally valid password wrap, without running Argon2id.
2575///
2576/// This is the cheap "is there a usable password path on this device" question. It does not
2577/// prove the password is known; only [`unwrap_master_key_with_password`] does.
2578#[uniffi::export]
2579#[must_use]
2580pub fn is_lock_password_wrap(bytes: Vec<u8>) -> bool {
2581 qualia2fa_core::lock::WrapHeader::parse(&bytes).is_ok()
2582}
2583
2584/// Opens the direct master key store of the current platform.
2585///
2586/// The `cfg` split is the one [`open_platform_key_store`] documents: opening a platform
2587/// store is fallible where the platform has one, and the placeholder compiled on the
2588/// remaining platforms cannot fail.
2589fn open_platform_direct_master_key_store() -> Result<PlatformDirectMasterKeyStore, FfiError> {
2590 #[cfg(any(target_os = "windows", target_os = "android"))]
2591 {
2592 PlatformDirectMasterKeyStore::open_best_available().map_err(platform_error)
2593 }
2594 #[cfg(not(any(target_os = "windows", target_os = "android")))]
2595 {
2596 Ok(PlatformDirectMasterKeyStore::open_best_available())
2597 }
2598}
2599
2600/// Opens the wrapped master key store of the current platform; see
2601/// [`open_platform_direct_master_key_store`] for the `cfg` split.
2602fn open_platform_wrapped_master_key_store() -> Result<PlatformWrappedMasterKeyStore, FfiError> {
2603 #[cfg(any(target_os = "windows", target_os = "android"))]
2604 {
2605 PlatformWrappedMasterKeyStore::open_best_available().map_err(platform_error)
2606 }
2607 #[cfg(not(any(target_os = "windows", target_os = "android")))]
2608 {
2609 Ok(PlatformWrappedMasterKeyStore::open_best_available())
2610 }
2611}
2612
2613/// Converts the bytes of a master key handed across the boundary into the library's type.
2614///
2615/// The library takes the material as a fixed-size array, so its length is checked by the
2616/// conversion itself. A wrong length is reported as the core's `master_key_wrong_length` —
2617/// the kind the host side already dispatches on for [`FfiVault::from_master_key`] — rather
2618/// than as a kind of its own. Both sides agree on the length by construction: the constant
2619/// used here is the library's, and it is the same 32 bytes the core's vault layer uses.
2620fn dik_master_key(bytes: Vec<u8>) -> Result<DikMasterKey, FfiError> {
2621 <[u8; MASTER_KEY_LEN]>::try_from(bytes)
2622 .map(DikMasterKey::from_bytes)
2623 .map_err(|bytes: Vec<u8>| {
2624 Error::MasterKeyWrongLength {
2625 expected: MASTER_KEY_LEN,
2626 actual: bytes.len(),
2627 }
2628 .into()
2629 })
2630}
2631
2632/// The image format of an [`FfiEntryIcon`].
2633///
2634/// The three are the formats the vault stores. The host side decodes the bytes itself, because
2635/// image decoding is a platform API (`BitmapFactory` on one platform, `ImageIO` on another) and
2636/// this crate does no decoding of its own. SVG is stored and exported but this version has no
2637/// renderer for it: a host that cannot draw one falls back to the letter badge.
2638#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
2639pub enum FfiIconMime {
2640 /// `image/png`.
2641 Png,
2642 /// `image/jpeg`.
2643 Jpeg,
2644 /// `image/svg+xml`.
2645 Svg,
2646}
2647
2648impl From<FfiIconMime> for qualia2fa_core::vault::IconMime {
2649 fn from(value: FfiIconMime) -> Self {
2650 match value {
2651 FfiIconMime::Png => Self::Png,
2652 FfiIconMime::Jpeg => Self::Jpeg,
2653 FfiIconMime::Svg => Self::Svg,
2654 }
2655 }
2656}
2657
2658impl From<qualia2fa_core::vault::IconMime> for FfiIconMime {
2659 fn from(value: qualia2fa_core::vault::IconMime) -> Self {
2660 match value {
2661 qualia2fa_core::vault::IconMime::Png => Self::Png,
2662 qualia2fa_core::vault::IconMime::Jpeg => Self::Jpeg,
2663 qualia2fa_core::vault::IconMime::Svg => Self::Svg,
2664 }
2665 }
2666}
2667
2668/// The picture an entry shows beside its name.
2669///
2670/// The bytes are the original file, not decoded pixels: the platform decodes them when the card
2671/// is drawn, and this crate only moves them. The type is **not** a secret, but its bytes are
2672/// never printed: `Debug` reports the format and the length.
2673#[derive(Clone, PartialEq, Eq, uniffi::Record)]
2674pub struct FfiEntryIcon {
2675 /// The format the bytes are in.
2676 pub mime: FfiIconMime,
2677 /// The original image file bytes.
2678 pub bytes: Vec<u8>,
2679}
2680
2681impl core::fmt::Debug for FfiEntryIcon {
2682 /// Prints the format and the length, never the picture.
2683 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2684 formatter
2685 .debug_struct("FfiEntryIcon")
2686 .field("mime", &self.mime)
2687 .field("bytes", &self.bytes.len())
2688 .finish()
2689 }
2690}
2691
2692impl FfiEntryIcon {
2693 /// Reads an icon as the host side sees it.
2694 fn from_core(icon: &qualia2fa_core::vault::EntryIcon) -> Self {
2695 Self {
2696 mime: icon.mime.into(),
2697 bytes: icon.bytes.clone(),
2698 }
2699 }
2700
2701 /// Builds a core icon, checking the node limit.
2702 fn to_core(&self) -> Result<qualia2fa_core::vault::EntryIcon, FfiError> {
2703 Ok(qualia2fa_core::vault::EntryIcon::new(
2704 self.mime.into(),
2705 self.bytes.clone(),
2706 )?)
2707 }
2708}
2709
2710/// A 2FA entry, as represented across the FFI boundary.
2711///
2712/// Relation to [`uri::OtpAuth`]: that type is the parse result of an `otpauth://`
2713/// URI, while this one is what a vault stores. The fields are similar but **not
2714/// equivalent** — a vault entry adds a display name, a note and timestamps, and
2715/// has no "URI type" field (HOTP is expressed by `counter` being `None`).
2716///
2717/// The core's [`EntryId`](qualia2fa_core::vault::EntryId) is 16 bytes; it crosses
2718/// the boundary as a 32-character lower-case hexadecimal string, the form the host
2719/// side uses as an opaque identifier (list item key, naming an entry to delete).
2720/// Strings have value semantics, whereas byte-array equality and hashing in
2721/// managed languages are often by reference.
2722#[derive(Clone, PartialEq, Eq, uniffi::Record)]
2723pub struct FfiVaultEntry {
2724 /// Entry identifier, a 32-character lower-case hexadecimal string.
2725 pub id: String,
2726 /// Display name.
2727 pub name: String,
2728 /// Issuer (service name).
2729 pub issuer: Option<String>,
2730 /// Account name.
2731 pub account: String,
2732 /// The secret, Base32 encoded (upper case). See the module documentation for
2733 /// the secret representation.
2734 pub secret_base32: String,
2735 /// Hash algorithm in use.
2736 pub algorithm: FfiAlgorithm,
2737 /// Number of digits in a code.
2738 pub digits: FfiDigits,
2739 /// The kind: what decides how a code is computed, and which parameters it uses.
2740 pub kind: FfiEntryKind,
2741 /// Whether a PIN is stored for this entry.
2742 ///
2743 /// Read-only: the core never hands a PIN back, because the only thing that needs to read one
2744 /// is the code computation, which happens there. A form can use this to show that a PIN
2745 /// exists, and passes the new value through [`FfiEntryKind`] when the user changes it.
2746 pub pin_set: bool,
2747 /// User note.
2748 pub note: String,
2749 /// Creation time in Unix seconds.
2750 pub created_at: u64,
2751 /// Last modification time in Unix seconds.
2752 pub updated_at: u64,
2753 /// The icon shown beside the name, when the entry has one.
2754 ///
2755 /// Read and written literally: an update that passes `None` **removes** the icon. The host
2756 /// side gets the value back from [`FfiVault::entry`] and hands the same value in when it edits
2757 /// other fields, which is what keeps an imported picture alive through an edit.
2758 pub icon: Option<FfiEntryIcon>,
2759}
2760
2761/// One child of a group: an entry or another group.
2762///
2763/// The two live in one type because the child list **is** the display order: an entry and a
2764/// child group have to be told apart to be drawn, and the order between them is what the user
2765/// arranged. The identifiers are the same 32-character hexadecimal strings the rest of the
2766/// surface uses.
2767#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
2768pub enum FfiGroupChild {
2769 /// An entry that belongs to this group.
2770 Entry {
2771 /// Entry identifier, 32 hexadecimal characters.
2772 id: String,
2773 },
2774 /// A named group whose children hang from this one.
2775 Group {
2776 /// Group identifier, 32 hexadecimal characters.
2777 id: String,
2778 },
2779}
2780
2781impl FfiGroupChild {
2782 /// Reads one child as the host side sees it.
2783 fn from_core(child: &GroupChild) -> Self {
2784 match child {
2785 GroupChild::Entry(id) => Self::Entry { id: id.to_string() },
2786 GroupChild::Group(id) => Self::Group {
2787 id: hex_id(id.as_bytes()),
2788 },
2789 }
2790 }
2791
2792 /// Turns one child back into the core's type.
2793 fn to_core(&self) -> Result<GroupChild, FfiError> {
2794 Ok(match self {
2795 Self::Entry { id } => GroupChild::Entry(parse_entry_id(id)?),
2796 Self::Group { id } => GroupChild::Group(parse_group_id(id)?),
2797 })
2798 }
2799}
2800
2801/// A group: its name and the ordered list of its children.
2802///
2803/// A group is a **sequence**, not a set: the child list is what the user arranged, and it is
2804/// synchronized between devices (see [`FfiConflict`] for what happens when two devices arrange
2805/// the same group differently).
2806///
2807/// The **top group** is the root of the tree: entries that belong to no named group live there,
2808/// it is always first in pre-order, it has no name, and its identifier is all zeroes;
2809/// [`FfiGroup::top`] says so rather than leaving the host to recognize that identifier.
2810#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
2811pub struct FfiGroup {
2812 /// The group's identifier, 32 hexadecimal characters.
2813 pub id: String,
2814 /// The name, empty for the top group.
2815 pub name: String,
2816 /// Whether this is the top group.
2817 pub top: bool,
2818 /// The children in display order: entries and child groups mixed.
2819 ///
2820 /// This is the truth. It is written whole by [`FfiVault::set_children`], the way the vault
2821 /// stores it, so a move is one call and has no intermediate state.
2822 pub children: Vec<FfiGroupChild>,
2823 /// The entries of this group, **derived** from `children`, in the same order.
2824 ///
2825 /// A view, not a second source of truth: it exists so that a caller that only arranges
2826 /// entries does not have to filter `children` itself. Writing the arrangement goes through
2827 /// [`FfiVault::set_children`] (whole list) or [`FfiVault::set_ordering`] (entries only).
2828 pub entry_ids: Vec<String>,
2829}
2830
2831impl FfiGroup {
2832 /// Reads one group as the host side sees it.
2833 fn from_core(id: &qualia2fa_core::vault::GroupId, group: &Group) -> Self {
2834 Self {
2835 id: hex_id(id.as_bytes()),
2836 name: group.name.clone(),
2837 top: id.is_top(),
2838 children: group
2839 .children
2840 .iter()
2841 .map(FfiGroupChild::from_core)
2842 .collect(),
2843 entry_ids: group.entry_ids().iter().map(ToString::to_string).collect(),
2844 }
2845 }
2846}
2847
2848impl core::fmt::Debug for FfiVaultEntry {
2849 /// Prints everything except the secret: it is the one field that must not reach a log, a
2850 /// panic message or a crash dump, and the import preview makes this type travel through
2851 /// diagnostics for the first time.
2852 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2853 formatter
2854 .debug_struct("FfiVaultEntry")
2855 .field("id", &self.id)
2856 .field("name", &self.name)
2857 .field("issuer", &self.issuer)
2858 .field("account", &self.account)
2859 .field("secret_base32", &"<redacted>")
2860 .field("algorithm", &self.algorithm)
2861 .field("digits", &self.digits)
2862 .field("kind", &self.kind)
2863 .field("pin_set", &self.pin_set)
2864 .field("note", &self.note)
2865 .field("created_at", &self.created_at)
2866 .field("updated_at", &self.updated_at)
2867 .field("icon", &self.icon)
2868 .finish_non_exhaustive()
2869 }
2870}
2871
2872impl FfiVaultEntry {
2873 /// Converts a core entry into its cross-boundary representation.
2874 fn from_core(id: &qualia2fa_core::vault::EntryId, entry: &Entry) -> Self {
2875 Self {
2876 id: id.to_string(),
2877 name: entry.name.clone(),
2878 issuer: entry.issuer.clone(),
2879 account: entry.account.clone(),
2880 secret_base32: entry.secret.to_base32(),
2881 algorithm: match entry.algorithm {
2882 Algorithm::Sha1 => FfiAlgorithm::Sha1,
2883 Algorithm::Sha256 => FfiAlgorithm::Sha256,
2884 Algorithm::Sha512 => FfiAlgorithm::Sha512,
2885 },
2886 digits: entry.digits.into(),
2887 kind: FfiEntryKind::from_core(&entry.kind),
2888 pin_set: entry.kind.motp_pin().is_some() || entry.kind.yandex_pin().is_some(),
2889 note: entry.note.clone(),
2890 created_at: entry.created_at,
2891 updated_at: entry.updated_at,
2892 icon: entry.icon.as_ref().map(FfiEntryIcon::from_core),
2893 }
2894 }
2895
2896 /// Builds a core entry. `now` fills in the creation and modification times.
2897 ///
2898 /// `existing` is the kind the stored entry has, when this is an update: a PIN the host did not
2899 /// supply is carried over from it (see [`FfiEntryKind`]).
2900 fn to_core(&self, now: u64, existing: Option<EntryKind>) -> Result<Entry, FfiError> {
2901 check_len("secret", &self.secret_base32, MAX_SECRET_CHARS)?;
2902 check_len("name", &self.name, MAX_VAULT_FIELD_CHARS)?;
2903 check_len("account", &self.account, MAX_VAULT_FIELD_CHARS)?;
2904 check_len("note", &self.note, MAX_VAULT_FIELD_CHARS)?;
2905 if let Some(issuer) = &self.issuer {
2906 check_len("issuer", issuer, MAX_VAULT_FIELD_CHARS)?;
2907 }
2908 let secret = OtpSecret::from_base32(&self.secret_base32)?;
2909 let icon = match &self.icon {
2910 Some(icon) => Some(icon.to_core()?),
2911 None => None,
2912 };
2913 Ok(Entry::new(
2914 self.name.clone(),
2915 self.issuer.clone(),
2916 self.account.clone(),
2917 secret,
2918 self.algorithm.into(),
2919 self.digits.to_core()?,
2920 self.kind.clone().into_core(existing)?,
2921 self.note.clone(),
2922 now,
2923 now,
2924 )?
2925 .with_icon(icon))
2926 }
2927
2928 /// Parses the 16-byte hexadecimal identifier.
2929 fn parse_id(&self) -> Result<qualia2fa_core::vault::EntryId, FfiError> {
2930 parse_entry_id(&self.id)
2931 }
2932}
2933
2934/// Parses the hexadecimal representation of an `EntryId` (32 lower-case
2935/// hexadecimal characters).
2936fn parse_entry_id(text: &str) -> Result<qualia2fa_core::vault::EntryId, FfiError> {
2937 check_len("entry_id", text, 32)?;
2938 let bytes = text.as_bytes();
2939 if bytes.len() != 32 {
2940 return Err(FfiError::Core {
2941 detail: Error::UriInvalidParameter {
2942 name: "entry_id".to_owned(),
2943 reason: "must be 32 hexadecimal characters".to_owned(),
2944 }
2945 .into(),
2946 });
2947 }
2948 let mut id = [0_u8; 16];
2949 for (index, slot) in id.iter_mut().enumerate() {
2950 let hi = hex_digit(bytes[index * 2])?;
2951 let lo = hex_digit(bytes[index * 2 + 1])?;
2952 *slot = (hi << 4) | lo;
2953 }
2954 Ok(qualia2fa_core::vault::EntryId::from_bytes(id))
2955}
2956
2957/// Parses the hexadecimal representation of a `GroupId` (32 lower-case hexadecimal characters).
2958///
2959/// The same shape as an entry identifier, and parsed the same way: a group identifier is opaque to
2960/// the host side, which only ever passes back what this layer gave it.
2961fn parse_group_id(text: &str) -> Result<qualia2fa_core::vault::GroupId, FfiError> {
2962 check_len("group_id", text, 32)?;
2963 let bytes = text.as_bytes();
2964 if bytes.len() != 32 {
2965 return Err(FfiError::Core {
2966 detail: Error::UriInvalidParameter {
2967 name: "group_id".to_owned(),
2968 reason: "must be 32 hexadecimal characters".to_owned(),
2969 }
2970 .into(),
2971 });
2972 }
2973 let mut id = [0_u8; 16];
2974 for (index, slot) in id.iter_mut().enumerate() {
2975 let hi = hex_digit(bytes[index * 2])?;
2976 let lo = hex_digit(bytes[index * 2 + 1])?;
2977 *slot = (hi << 4) | lo;
2978 }
2979 Ok(qualia2fa_core::vault::GroupId::from_bytes(id))
2980}
2981
2982/// One hexadecimal character → its value.
2983fn hex_digit(byte: u8) -> Result<u8, FfiError> {
2984 match byte {
2985 b'0'..=b'9' => Ok(byte - b'0'),
2986 b'a'..=b'f' => Ok(byte - b'a' + 10),
2987 b'A'..=b'F' => Ok(byte - b'A' + 10),
2988 _ => Err(FfiError::Core {
2989 detail: Error::UriInvalidParameter {
2990 name: "entry_id".to_owned(),
2991 reason: "contains a non-hexadecimal character".to_owned(),
2992 }
2993 .into(),
2994 }),
2995 }
2996}
2997
2998/// An unlocked vault.
2999///
3000/// # The master key does not cross the boundary
3001///
3002/// This object **holds** the master key on the Rust side, and entry operations
3003/// and serialization happen inside Rust. On the open vault → read entries →
3004/// write entries → serialize path, the master key never appears as bytes in
3005/// Kotlin or Swift.
3006///
3007/// # Host-side lifetime
3008///
3009/// The host side constructs this object when the user unlocks the application
3010/// and **discards** it (drops the reference) when the application goes to the
3011/// background or the user locks it. The Rust-side `Drop` wipes the master key
3012/// when the object is collected, but that depends on collection timing, so
3013/// locking **must not** rely on the collector: locking means actually dropping
3014/// the reference and letting the user interface ask for an unlock again.
3015#[derive(uniffi::Object)]
3016pub struct FfiVault {
3017 /// The core vault state, including the master key.
3018 ///
3019 /// UniFFI shares exported objects as `Arc<Self>`, and `Arc` grants no mutable
3020 /// borrow, so the state sits behind a container with interior mutability.
3021 /// `Mutex` and not `RefCell`: UniFFI's `#[Object]` requires `Send + Sync`, which
3022 /// `RefCell` does not provide.
3023 ///
3024 /// **The lock granularity is one method call**, and only in-memory work
3025 /// happens under the lock (hash-table insert and remove, copying entry
3026 /// fields). Encryption and Argon2 run outside the lock: one export
3027 /// derivation takes hundreds of milliseconds, and holding the lock across it
3028 /// would stall concurrent user-interface refreshes.
3029 ///
3030 /// Poisoning is absorbed with `unwrap_or_else`: this lock guards a data
3031 /// structure, and the methods inside it contain no panicking code. Should a
3032 /// panic happen anyway, recovering the data is preferable to failing every
3033 /// later call, which would leave the user unable to read or write entries.
3034 inner: std::sync::Mutex<VaultFile>,
3035}
3036
3037impl FfiVault {
3038 /// Builds from a core vault.
3039 fn wrap(inner: VaultFile) -> Arc<Self> {
3040 Arc::new(Self {
3041 inner: std::sync::Mutex::new(inner),
3042 })
3043 }
3044}
3045
3046#[uniffi::export]
3047impl FfiVault {
3048 /// Creates an empty vault from an existing master key.
3049 ///
3050 /// Used by two paths:
3051 ///
3052 /// - after [`FfiJoiner::complete`]: the master key has been obtained and
3053 /// written to the platform store, and a vault object to operate on is now
3054 /// needed;
3055 /// - after [`import_recovery_key`]: the same.
3056 ///
3057 /// # An empty vault is expected
3058 ///
3059 /// The master key and the entries are **transferred separately**: logging in
3060 /// brings the key, not the content. The first vault the host side obtains is
3061 /// necessarily empty, and the entries come back through sync or from a local
3062 /// file.
3063 ///
3064 /// # The master key crosses the boundary here, and only here
3065 ///
3066 /// The parameter is a 32-byte plaintext key — the case where the host side
3067 /// already holds the master key, which cannot be avoided. After this call
3068 /// the host side can drop those 32 bytes; everything later goes through this
3069 /// object.
3070 ///
3071 /// # Errors
3072 ///
3073 /// Returns [`Error::MasterKeyWrongLength`] when the length is not 32 bytes.
3074 #[uniffi::constructor]
3075 pub fn from_master_key(master_key: Vec<u8>) -> Result<Arc<Self>, FfiError> {
3076 let master = flow::master_key_from_bytes(master_key.into())?;
3077 Ok(Self::wrap(flow::empty_vault(master)))
3078 }
3079
3080 /// Re-wraps this vault's master key with a new unlock password.
3081 ///
3082 /// The old password is checked against `old_wrap` **and** against this vault's master key
3083 /// before a new wrap is produced. The caller writes the returned bytes atomically over
3084 /// `master.key.pw`; if the write fails, the old wrap is still there.
3085 ///
3086 /// The master key bytes are copied under the vault lock, and Argon2id runs after the lock
3087 /// is released, so a concurrent screen refresh is not held up by the derivation.
3088 ///
3089 /// # Errors
3090 ///
3091 /// - a malformed `old_wrap`: `lock_file_malformed` / `unsupported_format_version` /
3092 /// `lock_parameters_invalid`
3093 /// - a wrong old password, or one for another account: `lock_password_rejected`
3094 /// - an empty password: `lock_passphrase_empty`
3095 /// - an oversized password: [`FfiError::InputTooLong`]
3096 /// - a derivation or encryption failure: `lock_kdf_failed` / `lock_encryption_failed`
3097 pub fn rewrap_lock_password(
3098 &self,
3099 old_wrap: Vec<u8>,
3100 old_password: String,
3101 new_password: String,
3102 ) -> Result<Vec<u8>, FfiError> {
3103 check_len("old_password", &old_password, MAX_PASSPHRASE_CHARS)?;
3104 check_len("new_password", &new_password, MAX_PASSPHRASE_CHARS)?;
3105 let old = SecretString::from(old_password);
3106 let new = SecretString::from(new_password);
3107
3108 let master = {
3109 let file = self.lock();
3110 MasterKey::from_bytes(flow::master_key_to_bytes(file.master_key()))?
3111 };
3112 let opened = qualia2fa_core::lock::unwrap(&old_wrap, &old)?;
3113 if !opened.matches(&master) {
3114 return Err(Error::LockPasswordRejected.into());
3115 }
3116 qualia2fa_core::lock::wrap(&master, &new, &KdfParams::recommended()).map_err(Into::into)
3117 }
3118
3119 /// Wraps this vault's master key with the platform auth-bound store.
3120 ///
3121 /// The key bytes are copied under the vault lock and the platform prompt runs after the
3122 /// lock is released, so a Hello or biometric prompt does not freeze other vault calls.
3123 /// The returned bytes are the complete `master.key.bio` blob.
3124 ///
3125 /// # Errors
3126 ///
3127 /// [`FfiError::AuthBound`] for a cancelled gesture, an unavailable platform, or a
3128 /// platform failure. The master key is never returned to the host by this method.
3129 pub fn wrap_master_for_auth_bound(
3130 &self,
3131 store: Arc<FfiAuthBoundKeyStore>,
3132 ) -> Result<Vec<u8>, FfiError> {
3133 let master = {
3134 let file = self.lock();
3135 bioauth::MasterKey::from_bytes(file.master_key().expose()).map_err(auth_bound_error)?
3136 };
3137 store.store.wrap(&master).map_err(auth_bound_error)
3138 }
3139
3140 /// Registers a new device: generates a master key, obtains a device
3141 /// identity, and hands the master key to the platform key store.
3142 ///
3143 /// # What this step does and does not do
3144 ///
3145 /// **Does**: generate the master key → obtain the device identity → write
3146 /// the master key into [`FfiSecureStore`].
3147 /// **Does not** create a vault file or write to disk. It returns an empty
3148 /// [`FfiVault`]; the host side adds the first entries and then calls
3149 /// [`FfiVault::serialize`] to persist them.
3150 ///
3151 /// # Errors
3152 ///
3153 /// Returns the corresponding error when creating the device identity or
3154 /// writing the master key fails, and in that case **leaves nothing on disk**
3155 /// (this function writes no file). The platform key store may be
3156 /// unavailable; the host side should treat that as a state to report to the
3157 /// user rather than retry.
3158 #[uniffi::constructor]
3159 pub fn register(
3160 keys: Arc<dyn FfiDeviceKeys>,
3161 store: Arc<dyn FfiSecureStore>,
3162 label: String,
3163 ) -> Result<Arc<Self>, FfiError> {
3164 check_len("label", &label, MAX_LABEL_CHARS)?;
3165 let mut keys = ForeignDeviceKeys(keys);
3166 let mut store = ForeignSecureStore(store);
3167 let registered = flow::registration(&mut keys, &mut store, &label)?;
3168 Ok(Self::wrap(registered.into_vault()))
3169 }
3170
3171 /// Opens a vault file with the master key from the platform key store.
3172 ///
3173 /// # Errors
3174 ///
3175 /// Returns [`Error::DeviceNotRegistered`] (`kind` is
3176 /// `device_not_registered`) when the device was never registered, and
3177 /// [`Error::VaultDecryptionFailed`] (`kind` is `vault_decryption_failed`)
3178 /// when a master key exists but the file cannot be opened. The host side
3179 /// must distinguish the two: the first guides the user to register or log
3180 /// in, the second warns that the data may be damaged or that the master key
3181 /// was replaced.
3182 ///
3183 /// # An empty vault also tells whether the master key is right
3184 ///
3185 /// Format version 2 places a master key check value at the end of the file,
3186 /// so **a file with no entries is judged as well**: a wrong master key
3187 /// returns [`Error::VaultDecryptionFailed`] immediately instead of only
3188 /// after the user adds a first entry. The host side may therefore present
3189 /// "unlocked" as soon as the open succeeds.
3190 ///
3191 /// **Old version 1 files still cannot be judged** — such a file carries no
3192 /// information to judge with. Use the challenge-response of [`FfiVerifier`]
3193 /// for authoritative confirmation, since it does not depend on file
3194 /// content.
3195 #[uniffi::constructor]
3196 pub fn open(store: Arc<dyn FfiSecureStore>, data: Vec<u8>) -> Result<Arc<Self>, FfiError> {
3197 let store = ForeignSecureStore(store);
3198 let file = flow::open_vault(&store, &data)?;
3199 Ok(Self::wrap(file))
3200 }
3201
3202 /// Number of entries.
3203 #[must_use]
3204 pub fn entry_count(&self) -> u64 {
3205 self.lock().vault().len() as u64
3206 }
3207
3208 /// Whether there are no entries.
3209 #[must_use]
3210 pub fn is_empty(&self) -> bool {
3211 self.lock().vault().is_empty()
3212 }
3213
3214 /// Number of tombstones.
3215 ///
3216 /// A tombstone is the record that an entry was deleted, which lets the
3217 /// deletion propagate between devices. User interfaces normally need not
3218 /// show it, but it is useful when diagnosing.
3219 #[must_use]
3220 pub fn tombstone_count(&self) -> u64 {
3221 self.lock().vault().tombstone_count() as u64
3222 }
3223
3224 /// All current entries.
3225 ///
3226 /// # Order
3227 ///
3228 /// The order is **not guaranteed**, because the core indexes entries with a
3229 /// hash table. The host side must sort the list itself (by name, by creation
3230 /// time, and so on) and must not rely on this order being stable.
3231 #[must_use]
3232 pub fn entries(&self) -> Vec<FfiVaultEntry> {
3233 self.lock()
3234 .vault()
3235 .iter()
3236 .map(|(id, entry)| FfiVaultEntry::from_core(id, entry))
3237 .collect()
3238 }
3239
3240 /// Returns one entry by identifier. `None` when it does not exist.
3241 #[must_use]
3242 pub fn entry(&self, id: String) -> Option<FfiVaultEntry> {
3243 let id = parse_entry_id(&id).ok()?;
3244 self.lock()
3245 .vault()
3246 .get(&id)
3247 .map(|entry| FfiVaultEntry::from_core(&id, entry))
3248 }
3249
3250 /// Adds an entry and returns its identifier.
3251 ///
3252 /// `now` is Unix seconds, **supplied by the host side** — the core does not
3253 /// read a clock.
3254 pub fn add_entry(&self, entry: FfiVaultEntry, now: u64) -> Result<String, FfiError> {
3255 flow::check_now(now)?;
3256 let core = entry.to_core(now, None)?;
3257 let id = self.lock().vault_mut().insert(core)?;
3258 Ok(id.to_string())
3259 }
3260
3261 /// Modifies an existing entry.
3262 ///
3263 /// Returns [`Error::VaultEntryNotFound`] (`kind` is `vault_entry_not_found`)
3264 /// when the identifier is not found.
3265 ///
3266 /// # The identifier is preserved
3267 ///
3268 /// The identifier is the only thing by which devices refer to "the same
3269 /// entry": changing it reads as one deletion plus one insertion during sync,
3270 /// which misplaces that entry's history and tombstone on other devices.
3271 /// Modification must therefore **keep the identifier**.
3272 ///
3273 /// So this takes out the old entry, replaces its content and puts it back
3274 /// under the **same** identifier. The creation time is preserved as well: it
3275 /// records when the user added the entry, and letting it change would make
3276 /// the user interface reorder rows.
3277 pub fn update_entry(&self, entry: FfiVaultEntry, now: u64) -> Result<(), FfiError> {
3278 flow::check_now(now)?;
3279 let id = entry.parse_id()?;
3280 let mut vault = self.lock();
3281 // The stored kind is handed over so a PIN the host did not supply is carried across
3282 // instead of being dropped: the host was never shown it, and losing it would produce an
3283 // entry that silently stops matching.
3284 let existing = vault
3285 .vault()
3286 .get(&id)
3287 .ok_or(Error::VaultEntryNotFound)?
3288 .duplicate()?
3289 .kind;
3290 let mut core = entry.to_core(now, Some(existing))?;
3291 let created_at = vault
3292 .vault()
3293 .get(&id)
3294 .ok_or(Error::VaultEntryNotFound)?
3295 .created_at;
3296 core.created_at = created_at;
3297 vault.vault_mut().replace(id, core)?;
3298 Ok(())
3299 }
3300
3301 /// Deletes an entry, leaving a tombstone.
3302 ///
3303 /// Returns whether an entry was deleted (`false` means the identifier did
3304 /// not exist).
3305 ///
3306 /// # A tombstone is how deletion is recorded
3307 ///
3308 /// Erasing the record outright would make another device bring the entry
3309 /// **back** at the next sync, since it does not know about the deletion
3310 /// here. The tombstone is the fact "this entry was deleted" and travels with
3311 /// the entries.
3312 pub fn remove_entry(&self, id: String, now: u64) -> Result<bool, FfiError> {
3313 flow::check_now(now)?;
3314 let id = parse_entry_id(&id)?;
3315 Ok(self.lock().vault_mut().remove(&id, now).is_some())
3316 }
3317
3318 /// The groups in **pre-order**: the top group first, then each group's children depth-first.
3319 ///
3320 /// That is the tree the core stores and the order the interface draws; the host does not
3321 /// sort, because a sort would be a second opinion about the arrangement. `children` is the
3322 /// truth (entries and child groups mixed), and `entry_ids` is the derived view of its entry
3323 /// children.
3324 #[must_use]
3325 pub fn groups(&self) -> Vec<FfiGroup> {
3326 let vault = self.lock();
3327 vault
3328 .vault()
3329 .groups()
3330 .map(|(id, group)| FfiGroup::from_core(id, group))
3331 .collect()
3332 }
3333
3334 /// The ordered children of one group, or `None` when there is no such group.
3335 #[must_use]
3336 pub fn children_of(&self, group_id: String) -> Option<Vec<FfiGroupChild>> {
3337 let id = parse_group_id(&group_id).ok()?;
3338 let vault = self.lock();
3339 vault
3340 .vault()
3341 .children_of(&id)
3342 .map(|children| children.iter().map(FfiGroupChild::from_core).collect())
3343 }
3344
3345 /// The parent of a named group, or `None` for the top group and an unknown identifier.
3346 ///
3347 /// Worked out from the child lists, which is where the parent relationship lives; there is no
3348 /// parent field to read.
3349 #[must_use]
3350 pub fn parent_of(&self, group_id: String) -> Option<String> {
3351 let id = parse_group_id(&group_id).ok()?;
3352 let vault = self.lock();
3353 vault
3354 .vault()
3355 .parent_of(&id)
3356 .map(|parent| hex_id(parent.as_bytes()))
3357 }
3358
3359 /// The depth of a group: the top group is 0, its named children 1, and so on.
3360 #[must_use]
3361 pub fn depth_of(&self, group_id: String) -> Option<u64> {
3362 let id = parse_group_id(&group_id).ok()?;
3363 let vault = self.lock();
3364 vault
3365 .vault()
3366 .depth_of(&id)
3367 .map(|depth| u64::try_from(depth).unwrap_or(u64::MAX))
3368 }
3369
3370 /// The group an entry belongs to, as 32 hexadecimal characters.
3371 ///
3372 /// Always `Some` for an entry the vault holds: an entry that belongs to no named group belongs
3373 /// to the top group, which is a group like any other here.
3374 #[must_use]
3375 pub fn group_of(&self, entry_id: String) -> Option<String> {
3376 let id = parse_entry_id(&entry_id).ok()?;
3377 let vault = self.lock();
3378 vault
3379 .vault()
3380 .group_of(&id)
3381 .map(|group| hex_id(group.as_bytes()))
3382 }
3383
3384 /// Creates a named group under the **top group** and returns its identifier.
3385 ///
3386 /// # Errors
3387 ///
3388 /// The core's error for a name longer than a vault field allows (`field_too_long`).
3389 pub fn create_group(&self, name: String) -> Result<String, FfiError> {
3390 check_len("group_name", &name, MAX_VAULT_FIELD_CHARS)?;
3391 if name.is_empty() {
3392 return Err(FfiError::InputEmpty {
3393 kind: "group_name".to_owned(),
3394 });
3395 }
3396 let id = self.lock().vault_mut().create_group(name)?;
3397 Ok(hex_id(id.as_bytes()))
3398 }
3399
3400 /// Creates a named group as the **last child** of `parent_id` and returns its identifier.
3401 ///
3402 /// # Errors
3403 ///
3404 /// [`Error::VaultGroupNotFound`] (`kind` is `vault_group_not_found`) for an unknown parent,
3405 /// [`Error::VaultGroupDepthExceeded`] (`vault_group_depth_exceeded`) when the new group would
3406 /// be nested deeper than the interface can draw, and `field_too_long` for an over-long name.
3407 pub fn create_group_in(&self, parent_id: String, name: String) -> Result<String, FfiError> {
3408 check_len("group_name", &name, MAX_VAULT_FIELD_CHARS)?;
3409 if name.is_empty() {
3410 return Err(FfiError::InputEmpty {
3411 kind: "group_name".to_owned(),
3412 });
3413 }
3414 let parent = parse_group_id(&parent_id)?;
3415 let id = self.lock().vault_mut().create_group_in(&parent, name)?;
3416 Ok(hex_id(id.as_bytes()))
3417 }
3418
3419 /// Renames a group.
3420 ///
3421 /// # Errors
3422 ///
3423 /// [`Error::VaultTopGroupImmutable`] (`kind` is `vault_top_group_immutable`) for the top group,
3424 /// which has no name to edit, and [`Error::VaultGroupNotFound`] for an unknown identifier.
3425 pub fn rename_group(&self, group_id: String, name: String) -> Result<(), FfiError> {
3426 check_len("group_name", &name, MAX_VAULT_FIELD_CHARS)?;
3427 let id = parse_group_id(&group_id)?;
3428 self.lock().vault_mut().rename_group(&id, name)?;
3429 Ok(())
3430 }
3431
3432 /// Replaces the whole child list of a group: the one write behind every move.
3433 ///
3434 /// A list is a sequence, and this is how the vault stores one, so a move, an insertion or a
3435 /// deletion is a single call with no intermediate state. A child the new list drops is not
3436 /// deleted: an entry returns to the top group and a child group moves to this group's parent.
3437 ///
3438 /// # Errors
3439 ///
3440 /// - `vault_group_not_found`: the group or a child group does not exist.
3441 /// - `vault_group_unknown_entry`: a child names an entry the vault does not hold.
3442 /// - `vault_group_duplicate_child`: the list names one child twice.
3443 /// - `vault_group_cycle`: the list would put a group inside itself.
3444 /// - `vault_group_depth_exceeded`: the moved subtree would be nested too deeply.
3445 pub fn set_children(
3446 &self,
3447 group_id: String,
3448 children: Vec<FfiGroupChild>,
3449 ) -> Result<(), FfiError> {
3450 let id = parse_group_id(&group_id)?;
3451 let mut parsed = Vec::with_capacity(children.len());
3452 for child in &children {
3453 parsed.push(child.to_core()?);
3454 }
3455 self.lock().vault_mut().set_children(&id, parsed)?;
3456 Ok(())
3457 }
3458
3459 /// Removes a group.
3460 ///
3461 /// Nothing is deleted: the group's children move to its parent, keeping their relative order
3462 /// and their place in that parent's list.
3463 ///
3464 /// # Errors
3465 ///
3466 /// [`Error::VaultTopGroupImmutable`] for the top group, and [`Error::VaultGroupNotFound`] for
3467 /// an unknown identifier.
3468 pub fn remove_group(&self, group_id: String) -> Result<(), FfiError> {
3469 let id = parse_group_id(&group_id)?;
3470 self.lock().vault_mut().remove_group(&id)?;
3471 Ok(())
3472 }
3473
3474 /// Replaces the **entry** children of a group, keeping the positions of its child groups.
3475 ///
3476 /// The list is the whole entry sequence, not a change to it: a host that reorders one row
3477 /// sends the result, which is both simpler and safer than describing a move. Entries the new
3478 /// list drops return to the top group unless another group claims them.
3479 ///
3480 /// # Errors
3481 ///
3482 /// [`Error::VaultGroupNotFound`] for an unknown group,
3483 /// [`Error::VaultGroupUnknownEntry`] for an identifier the vault does not hold, and
3484 /// [`Error::VaultGroupDuplicateChild`] when the list names one entry twice.
3485 pub fn set_ordering(&self, group_id: String, entry_ids: Vec<String>) -> Result<(), FfiError> {
3486 let id = parse_group_id(&group_id)?;
3487 let mut parsed = Vec::with_capacity(entry_ids.len());
3488 for entry_id in &entry_ids {
3489 parsed.push(parse_entry_id(entry_id)?);
3490 }
3491 self.lock().vault_mut().set_ordering(&id, parsed)?;
3492 Ok(())
3493 }
3494
3495 /// Moves an entry into a group, appending it to that group's ordering.
3496 ///
3497 /// This is the "move to group" operation: the entry leaves wherever it was, so the host does
3498 /// not have to know where it came from.
3499 ///
3500 /// # Errors
3501 ///
3502 /// [`Error::VaultEntryNotFound`] for an unknown entry and [`Error::VaultGroupNotFound`] for an
3503 /// unknown group.
3504 pub fn assign_to_group(&self, entry_id: String, group_id: String) -> Result<(), FfiError> {
3505 let entry = parse_entry_id(&entry_id)?;
3506 let group = parse_group_id(&group_id)?;
3507 self.lock().vault_mut().assign_to_group(&entry, &group)?;
3508 Ok(())
3509 }
3510
3511 /// Serializes into vault file bytes for the host side to persist.
3512 ///
3513 /// The host side should write the file **atomically** (write a temporary
3514 /// file, then rename) rather than overwrite in place: losing power halfway
3515 /// leaves a file that cannot be opened, and the user's only copy of the data
3516 /// is inside it. The core provides `vault::write_file_atomically`, but it
3517 /// performs I/O and is not part of this library's exported surface.
3518 pub fn serialize(&self) -> Result<Vec<u8>, FfiError> {
3519 Ok(self.lock().serialize()?)
3520 }
3521
3522 /// Computes the current code for one entry.
3523 ///
3524 /// `unix_time` is supplied by the host side (the core does not read a clock). The **kind of
3525 /// the entry** decides how the code is computed: a Steam Guard entry produces five
3526 /// characters, an mOTP entry six hexadecimal ones, a Yandex entry eight lower-case letters.
3527 /// The host does not have to know which function to call, which is what used to make those
3528 /// three arrive as ordinary TOTP entries and produce codes that never matched.
3529 ///
3530 /// # Errors
3531 ///
3532 /// [`Error::PinRequired`] (`kind` is `pin_required`) when the entry computes with a PIN and
3533 /// has none, and [`Error::VaultEntryNotFound`] when the identifier is unknown. An HOTP entry
3534 /// has no time-based code: use [`FfiVault::hotp_for`] with the counter the user is on.
3535 pub fn code_for(&self, id: String, unix_time: u64) -> Result<String, FfiError> {
3536 self.with_entry(&id, |entry| match entry.kind {
3537 EntryKind::Totp { period } => Ok(otp::totp_code(
3538 &entry.secret,
3539 unix_time,
3540 period,
3541 entry.algorithm,
3542 entry.digits,
3543 )?),
3544 EntryKind::Motp { period, ref pin } => Ok(motp::motp_code(
3545 &entry.secret,
3546 pin.as_ref(),
3547 period,
3548 unix_time,
3549 )?),
3550 EntryKind::Yandex { period, ref pin } => Ok(yaotp::yaotp_code(
3551 &entry.secret,
3552 pin.as_ref(),
3553 period,
3554 unix_time,
3555 )?),
3556 EntryKind::Steam => Ok(otp::steam_guard_code(
3557 &entry.secret,
3558 unix_time,
3559 otp::STEAM_PERIOD,
3560 entry.algorithm,
3561 )?),
3562 EntryKind::Hotp { .. } => Err(FfiError::WrongCodeKind {
3563 kind: "hotp".to_owned(),
3564 wanted: "time".to_owned(),
3565 }),
3566 })
3567 }
3568
3569 /// Computes the code for one HOTP entry at the given counter.
3570 ///
3571 /// The counter comes from the host side: advancing it is the user's decision (the interface
3572 /// shows the next code and only then moves on), and this layer does not store a counter it
3573 /// was not asked to store. Use [`FfiVault::code_for`] for every other kind.
3574 pub fn hotp_for(&self, id: String, counter: u64) -> Result<String, FfiError> {
3575 self.with_entry(&id, |entry| match entry.kind {
3576 EntryKind::Hotp { .. } => Ok(otp::hotp_code(
3577 &entry.secret,
3578 counter,
3579 entry.algorithm,
3580 entry.digits,
3581 )),
3582 _ => Err(FfiError::WrongCodeKind {
3583 kind: "not_hotp".to_owned(),
3584 wanted: "counter".to_owned(),
3585 }),
3586 })
3587 }
3588 /// Exports a recovery key file protected by a passphrase.
3589 ///
3590 /// # KDF parameters
3591 ///
3592 /// Passing `None` for `memory_kib` / `iterations` / `parallelism` uses the
3593 /// core defaults (64 MiB / 3 iterations / 1 lane). The host side **must
3594 /// not** lower them for speed: the export cost is paid once, whereas an
3595 /// attacker who obtains the file attacks it offline, so lower parameters
3596 /// weaken the last line of defence directly.
3597 ///
3598 /// # Errors
3599 ///
3600 /// Returns [`Error::RecoveryParametersInvalid`] (`kind` is
3601 /// `recovery_parameters_invalid`) when the parameters fall outside the range
3602 /// the core allows (memory at most 1 GiB, at most 1000 iterations). **Both
3603 /// bounds are checked**, because the parameters can come from the host
3604 /// side's sliders and from **someone else's** file.
3605 ///
3606 /// # Handling the return value
3607 ///
3608 /// The caller receives **encrypted** file bytes, not the master key. The
3609 /// host side should only write them to a location the user chose or show
3610 /// them as a QR code for the user to copy down, and should keep them out of
3611 /// the clipboard history. The passphrase itself is not part of the return
3612 /// value and must not be persisted by the host side.
3613 pub fn export_recovery_key(
3614 &self,
3615 passphrase: String,
3616 memory_kib: Option<u64>,
3617 iterations: Option<u64>,
3618 parallelism: Option<u64>,
3619 ) -> Result<Vec<u8>, FfiError> {
3620 check_len("passphrase", &passphrase, MAX_PASSPHRASE_CHARS)?;
3621 let params = kdf_params(memory_kib, iterations, parallelism)?;
3622 let vault = self.lock();
3623 Ok(flow::export_recovery_key(
3624 vault.master_key(),
3625 &SecretString::from(passphrase),
3626 Some(params),
3627 )?)
3628 }
3629}
3630
3631impl FfiVault {
3632 /// Takes the lock, ignoring poisoning.
3633 ///
3634 /// Poisoning means a thread panicked while holding the lock. The data
3635 /// structure's **invariants** still hold at that point (this lock guards
3636 /// hash-table insert and remove, which have no half-modified intermediate
3637 /// state), so recovering is preferable to failing every later call, which
3638 /// would leave the user unable to read or write entries at all.
3639 fn lock(&self) -> std::sync::MutexGuard<'_, VaultFile> {
3640 self.inner
3641 .lock()
3642 .unwrap_or_else(std::sync::PoisonError::into_inner)
3643 }
3644
3645 /// Computes the three derived fields of an entry.
3646 ///
3647 /// It does not return `&Entry`: entries live behind the `MutexGuard`, which
3648 /// is released when the function returns, so a borrow cannot escape. The
3649 /// core's `OtpSecret` deliberately does not implement `Clone`, so this
3650 /// **computes the code and returns the string** rather than copying the
3651 /// entry or the secret out.
3652 fn with_entry(
3653 &self,
3654 id: &str,
3655 compute: impl FnOnce(&Entry) -> Result<String, FfiError>,
3656 ) -> Result<String, FfiError> {
3657 let id = parse_entry_id(id)?;
3658 let vault = self.lock();
3659 let entry = vault.vault().get(&id).ok_or(Error::VaultEntryNotFound)?;
3660 compute(entry)
3661 }
3662}
3663
3664/// Opens a vault with the master key held by an [`FfiVault`].
3665///
3666/// # The return value is a byte string, not a printable short code
3667///
3668/// The recovery key file is designed so that a user can copy it onto paper or
3669/// into a QR code. It is **not** the master key: the master key inside it is
3670/// encrypted under a key derived from the user passphrase with Argon2id (see the
3671/// core `recovery` module). Returning `Vec<u8>` is therefore safe, but the host
3672/// side should still keep it out of logs and the clipboard history.
3673///
3674/// # Relation to [`FfiVault::export_recovery_key`]
3675///
3676/// Both do the same thing; only the source of the master key differs:
3677///
3678/// - [`FfiVault::export_recovery_key`]: the device is **already unlocked** and the
3679/// master key is on the Rust side. Prefer that path — the master key does not
3680/// cross the boundary at all.
3681/// - this function: the caller only holds master key bytes (for example just
3682/// obtained from [`FfiJoiner::complete`], before a vault exists). The master
3683/// key crosses the boundary once here.
3684#[uniffi::export]
3685pub fn export_recovery_key_from_master(
3686 master_key: Vec<u8>,
3687 passphrase: String,
3688 memory_kib: Option<u64>,
3689 iterations: Option<u64>,
3690 parallelism: Option<u64>,
3691) -> Result<Vec<u8>, FfiError> {
3692 check_len("passphrase", &passphrase, MAX_PASSPHRASE_CHARS)?;
3693 let params = kdf_params(memory_kib, iterations, parallelism)?;
3694 let master = flow::master_key_from_bytes(master_key.into())?;
3695 Ok(flow::export_recovery_key(
3696 &master,
3697 &SecretString::from(passphrase),
3698 Some(params),
3699 )?)
3700}
3701
3702/// Recovers the master key from a recovery key file with a passphrase and
3703/// **writes it into the platform key store**.
3704///
3705/// The write goes through the core `SecureStore` abstraction, so this path and
3706/// login share the same ordering guarantees at the storage step.
3707///
3708/// Returns the 32 bytes of the master key. The host side either passes them to
3709/// [`FfiSecureStore::store_master_key`] or drops them, since this function has
3710/// already written the store once; building an [`FfiVault`] from the returned bytes
3711/// avoids a second key-store read, which is a hardware access that prompts for
3712/// biometrics on some platforms.
3713///
3714/// # Errors
3715///
3716/// A wrong passphrase returns [`Error::RecoveryAuthenticationFailed`] (`kind` is
3717/// `recovery_authentication_failed`); a malformed file returns
3718/// [`Error::RecoveryFileMalformed`]; out-of-range parameters return
3719/// [`Error::RecoveryParametersInvalid`].
3720///
3721/// **A wrong passphrase and a damaged file must be distinguished**: the first
3722/// asks the user to type it again, the second reports that the file is unusable.
3723#[uniffi::export]
3724pub fn import_recovery_key(
3725 file: Vec<u8>,
3726 passphrase: String,
3727 store: Arc<dyn FfiSecureStore>,
3728) -> Result<Vec<u8>, FfiError> {
3729 check_len("passphrase", &passphrase, MAX_PASSPHRASE_CHARS)?;
3730 let mut store = ForeignSecureStore(store);
3731 let master = flow::import_recovery_key(&file, &SecretString::from(passphrase), &mut store)?;
3732 Ok(flow::master_key_to_bytes(&master).expose_secret().to_vec())
3733}
3734
3735/// Writes a master key into the platform key store.
3736///
3737/// For use by the host side after [`FfiJoiner::complete`], which returns master
3738/// key bytes and leaves the timing of the store write to the host side. This is
3739/// a separate step rather than something `complete` does itself, because
3740/// "login finished" and "key saved" are two actions in a user interface.
3741///
3742/// # Errors
3743///
3744/// Returns [`Error::MasterKeyWrongLength`] when the key length is not 32 bytes.
3745#[uniffi::export]
3746pub fn store_master_key(
3747 master_key: Vec<u8>,
3748 store: Arc<dyn FfiSecureStore>,
3749) -> Result<(), FfiError> {
3750 let master = flow::master_key_from_bytes(master_key.into())?;
3751 let mut store = ForeignSecureStore(store);
3752 store.store_master_key(&master)?;
3753 Ok(())
3754}
3755
3756// ── Login: initiator and joiner ──────────────────────────────────────────
3757
3758/// The initiator of a login: a device that is **already logged in** and holds
3759/// the master key.
3760///
3761/// It displays the QR code, receives the request and hands the master key to the
3762/// other device in encrypted form.
3763///
3764/// # One login serves exactly one request
3765///
3766/// The core enforces this with the **type system** (`authorize` consumes the
3767/// pending session), so a second call necessarily fails and the host side needs
3768/// no flag of its own. All the host side does is keep one QR code per
3769/// [`FfiLogin`] instance and drop it after a successful authorization.
3770///
3771/// # Replay protection across restarts
3772///
3773/// The core's `Login` guarantees single use within one process. Keeping replay
3774/// protection across application restarts would require persisting used session
3775/// identifiers and restoring them at the next login. **This layer does not expose
3776/// that path**: the core's `NonceLog` has no serialization entry point, so
3777/// cross-restart protection is presently unreachable.
3778///
3779/// That is not a gap for the current protocol: a pending session itself does not
3780/// survive a restart (it contains an ephemeral private key that the host side
3781/// cannot write to disk), so there is nothing to replay.
3782#[derive(uniffi::Object)]
3783pub struct FfiLogin {
3784 /// The core initiator state.
3785 inner: std::sync::Mutex<Login>,
3786}
3787
3788#[uniffi::export]
3789impl FfiLogin {
3790 /// Starts a login and returns the text to display as a QR code.
3791 #[uniffi::constructor]
3792 pub fn begin() -> Result<Arc<Self>, FfiError> {
3793 Ok(Arc::new(Self {
3794 inner: std::sync::Mutex::new(Login::new()?),
3795 }))
3796 }
3797
3798 /// The text to display as a QR code.
3799 ///
3800 /// This is the **only** channel that has to be shown to the other device. It
3801 /// contains no master key (see the module documentation of the core
3802 /// `pairing`), but it is the credential for this session, so it must not be
3803 /// sent to a chat log or shared as a screenshot.
3804 pub fn payload(&self) -> String {
3805 self.lock().payload().to_base32()
3806 }
3807
3808 /// Accepts the request the other device sends after scanning the QR code and
3809 /// returns the reply bytes to send back.
3810 ///
3811 /// # Authorization is user confirmation
3812 ///
3813 /// The moment this function is called, the master key is encrypted and handed
3814 /// to the other device. The host side **must** therefore obtain user
3815 /// confirmation before calling it (for example by asking whether the user
3816 /// really started a login on a new device). The core cannot make that
3817 /// decision for the host side, and should not.
3818 ///
3819 /// # Errors
3820 ///
3821 /// A request that did not come from this session, tampered ciphertext, and a
3822 /// login that has already been authorized all return
3823 /// [`Error::PairingAuthenticationFailed`]. **The reason is deliberately not
3824 /// split further**, so that an attacker gains no oracle distinguishing "almost
3825 /// succeeded"; host-side text should stay generic as well.
3826 pub fn authorize(&self, vault: Arc<FfiVault>, msg1: Vec<u8>) -> Result<Vec<u8>, FfiError> {
3827 let vault = vault.lock();
3828 let mut login = self.lock();
3829 Ok(login.authorize(&msg1, vault.master_key())?)
3830 }
3831
3832 /// The device identifier declared by the requester (a value exists only after
3833 /// a successful authorization).
3834 pub fn joiner_device_id(&self) -> Option<String> {
3835 self.lock().joiner_device().map(|id| id.to_hex())
3836 }
3837}
3838
3839impl FfiLogin {
3840 /// Takes the lock, ignoring poisoning (see [`FfiVault::lock`]).
3841 fn lock(&self) -> std::sync::MutexGuard<'_, Login> {
3842 self.inner
3843 .lock()
3844 .unwrap_or_else(std::sync::PoisonError::into_inner)
3845 }
3846}
3847
3848/// The joiner of a login: a new device that does not have the master key yet.
3849///
3850/// # The lifecycle is scan → request → reply → complete
3851///
3852/// The request must be issued and completed by the **same** object: the session
3853/// identifier is determined by this session's ephemeral key, so a different
3854/// object means a different session and the reply cannot be opened. The core
3855/// expresses this in the type system (`request` borrows `&self` instead of
3856/// consuming it, and only `complete` consumes), so as long as the host side does
3857/// not rebuild the object the behavior is right.
3858#[derive(uniffi::Object)]
3859pub struct FfiJoiner {
3860 /// The core joiner state.
3861 inner: std::sync::Mutex<Joiner>,
3862}
3863
3864#[uniffi::export]
3865impl FfiJoiner {
3866 /// Establishes a session from the scanned QR code text. `label` is the label
3867 /// of this device's identity in the platform key store.
3868 ///
3869 /// This step does not send the request; the core keeps `new` and `request` as
3870 /// two steps and this layer keeps the same granularity. Sending the request
3871 /// needs the device identity, and obtaining it prompts for biometrics on some
3872 /// platforms, so the host side can tell the user that a local key is about to
3873 /// be created first.
3874 ///
3875 /// # Errors
3876 ///
3877 /// Returns [`Error::PairingPayloadMalformed`] when the text is not a valid QR
3878 /// payload. Note that "not a valid payload" and "valid payload but a
3879 /// mismatched session" are two different things; the latter only surfaces at
3880 /// [`FfiJoiner::complete`].
3881 #[uniffi::constructor]
3882 pub fn from_qr(text: String, label: String) -> Result<Arc<Self>, FfiError> {
3883 check_len("qr", &text, MAX_QR_TEXT_CHARS)?;
3884 check_len("label", &label, MAX_LABEL_CHARS)?;
3885 Ok(Arc::new(Self {
3886 inner: std::sync::Mutex::new(Joiner::from_base32(&text, &label)?),
3887 }))
3888 }
3889
3890 /// The request bytes to send to the initiator. **Idempotent**: repeated calls
3891 /// return the same message and do not sign again.
3892 ///
3893 /// Signing again would produce a new session identifier and the initiator
3894 /// would no longer recognize it as the same login, so idempotence is a
3895 /// correctness requirement rather than an optimization.
3896 pub fn request(&self, keys: Arc<dyn FfiDeviceKeys>) -> Result<Vec<u8>, FfiError> {
3897 let mut keys = ForeignDeviceKeys(keys);
3898 let mut joiner = self.lock();
3899 Ok(joiner.request(&mut keys)?)
3900 }
3901
3902 /// The device identity this device declares in this session (SEC1
3903 /// uncompressed encoding, 65 bytes).
3904 ///
3905 /// The initiator should display it together with
3906 /// [`FfiLogin::joiner_device_id`] for the user to confirm, so that the user
3907 /// can tell whether the requesting device is the expected one. Returns `None`
3908 /// after completion.
3909 #[must_use]
3910 pub fn identity(&self) -> Option<Vec<u8>> {
3911 self.lock().identity().map(|bytes| bytes.to_vec())
3912 }
3913
3914 /// Completes the login with the initiator's reply: verifies the reply, takes
3915 /// out the master key and **writes it into the platform key store**.
3916 ///
3917 /// Returns the 32 bytes of the master key so that the host side can build an
3918 /// [`FfiVault`] right away with [`FfiVault::from_master_key`] instead of
3919 /// reading the key store again; every read is another hardware access that
3920 /// prompts for biometrics on some platforms.
3921 ///
3922 /// # This is where the reply is actually verified
3923 ///
3924 /// Decryption and authentication of the reply happen here, not in
3925 /// [`FfiJoiner::from_qr`]. A login failure is therefore only trustworthy from
3926 /// this function.
3927 ///
3928 /// # Single use
3929 ///
3930 /// The core's session is single use (`complete` takes it internally), so a
3931 /// second call returns [`Error::PairingAuthenticationFailed`] and **not** a
3932 /// different error from a forged reply. Reporting "already used" separately
3933 /// would tell an attacker that the replay was detected.
3934 ///
3935 /// # Errors
3936 ///
3937 /// A reply from another session, tampered ciphertext, an initiator using a
3938 /// different master key, and a completed login all return
3939 /// [`Error::PairingAuthenticationFailed`]. A failure to write the master key
3940 /// into the platform store returns [`Error::SecureStoreWriteFailed`]; in that
3941 /// case **the master key is left nowhere** and the host side should have the
3942 /// user log in again.
3943 pub fn complete(
3944 &self,
3945 msg2: Vec<u8>,
3946 keys: Arc<dyn FfiDeviceKeys>,
3947 store: Arc<dyn FfiSecureStore>,
3948 ) -> Result<Vec<u8>, FfiError> {
3949 let mut keys = ForeignDeviceKeys(keys);
3950 let mut store = ForeignSecureStore(store);
3951 let master = self.lock().complete(&msg2, &mut keys, &mut store)?;
3952 Ok(flow::master_key_to_bytes(&master).expose_secret().to_vec())
3953 }
3954}
3955
3956impl FfiJoiner {
3957 /// Takes the lock, ignoring poisoning (see [`FfiVault::lock`]).
3958 fn lock(&self) -> std::sync::MutexGuard<'_, Joiner> {
3959 self.inner
3960 .lock()
3961 .unwrap_or_else(std::sync::PoisonError::into_inner)
3962 }
3963}
3964
3965// ── Challenge-response: proving possession of the master key ─────────────
3966//
3967// This section has **three messages**, and their direction is the opposite of
3968// the usual "request/response":
3969//
3970// 1. verifier → responder: the challenge (`FfiVerifier::challenge` →
3971// `FfiResponder::accept`)
3972// 2. responder → verifier: the response (`FfiResponder::respond` →
3973// `FfiVerifier::verify`)
3974// 3. verifier → responder: the confirmation tag (`FfiVerifier::session_id` and
3975// `FfiVerifier::confirmation` → `FfiResponder::confirm`)
3976//
3977// The third step is easy to omit, and it is the proof in the **opposite**
3978// direction: the responder uses it to confirm that the party answering really
3979// is the verifier that sent the challenge. With only the first two steps,
3980// anyone can send a challenge and then pretend to have verified it.
3981
3982/// The verifier: confirms that the other side actually holds the master key.
3983///
3984/// # Where it is used
3985///
3986/// 1. **Confirming a successful unlock.** A version 2 vault file already tells
3987/// whether the master key is right (see [`FfiVault::open`]), so this path
3988/// mainly serves **old version 1 files** and the case of having only a master
3989/// key and no file yet.
3990/// 2. **Cross-device identity confirmation.** It does not depend on file
3991/// content, so it is the only proof available when the file is missing or
3992/// damaged.
3993///
3994/// # Difference from pairing
3995///
3996/// Pairing hands the master key to the other side; challenge-response proves
3997/// possession of the master key without handing it over. The former happens once
3998/// at login, the latter at any time. **Challenge-response is not login**: it
3999/// transfers no key.
4000#[derive(uniffi::Object)]
4001pub struct FfiVerifier {
4002 /// The core verifier state. `verify` consumes it, hence the `Option`.
4003 inner: std::sync::Mutex<Option<auth::Verifier>>,
4004 /// The challenge bytes to send in the first step.
4005 challenge: Vec<u8>,
4006 /// The session identifier computed after the second step, sent back to the
4007 /// other side in the third step.
4008 session_id: std::sync::Mutex<Option<[u8; AUTH_SESSION_ID_LEN]>>,
4009 /// The confirmation tag computed after the second step, sent back to the
4010 /// other side in the third step.
4011 confirmation: std::sync::Mutex<Option<[u8; AUTH_CONFIRM_LEN]>>,
4012}
4013
4014#[uniffi::export]
4015impl FfiVerifier {
4016 /// Starts a challenge.
4017 #[uniffi::constructor]
4018 pub fn begin() -> Result<Arc<Self>, FfiError> {
4019 let (challenge, verifier) = auth::begin()?;
4020 Ok(Arc::new(Self {
4021 inner: std::sync::Mutex::new(Some(verifier)),
4022 challenge,
4023 session_id: std::sync::Mutex::new(None),
4024 confirmation: std::sync::Mutex::new(None),
4025 }))
4026 }
4027
4028 /// The challenge bytes to send to the other side.
4029 #[must_use]
4030 pub fn challenge(&self) -> Vec<u8> {
4031 self.challenge.clone()
4032 }
4033
4034 /// Verifies the other side's response and returns `true` on success.
4035 ///
4036 /// Failure returns [`Error::AuthProofFailed`] (`kind` is `auth_proof_failed`);
4037 /// **do not** read anything else into it: this error is produced only when a
4038 /// tag does not match.
4039 ///
4040 /// # Single use
4041 ///
4042 /// The challenge is single use (the core's `verify` consumes `self`). A
4043 /// repeated verification returns [`Error::AuthProofFailed`] — the same error
4044 /// as a forged response, for the reason given at [`FfiJoiner::complete`]: no
4045 /// distinguishing signal for a replayer.
4046 ///
4047 /// # The third step follows a success
4048 ///
4049 /// After receiving `true`, the host side must send
4050 /// [`FfiVerifier::session_id`] and [`FfiVerifier::confirmation`] back to the
4051 /// other side, which verifies them with [`FfiResponder::confirm`]. **Omitting
4052 /// the third step leaves the authentication half done**: the responder has no
4053 /// way to confirm that the party answering really is the verifier.
4054 pub fn verify(&self, response: Vec<u8>, vault: Arc<FfiVault>) -> Result<bool, FfiError> {
4055 let verifier = {
4056 let mut guard = self
4057 .inner
4058 .lock()
4059 .unwrap_or_else(std::sync::PoisonError::into_inner);
4060 guard.take().ok_or(Error::AuthProofFailed)?
4061 };
4062 let vault = vault.lock();
4063 let verified = verifier.verify(&response, vault.master_key())?;
4064 *self
4065 .session_id
4066 .lock()
4067 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(*verified.session_id());
4068 *self
4069 .confirmation
4070 .lock()
4071 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(verified.confirmation());
4072 Ok(true)
4073 }
4074
4075 /// The session identifier to send back in the third step. `None` before a
4076 /// successful verification.
4077 #[must_use]
4078 pub fn session_id(&self) -> Option<Vec<u8>> {
4079 self.session_id
4080 .lock()
4081 .unwrap_or_else(std::sync::PoisonError::into_inner)
4082 .map(|id| id.to_vec())
4083 }
4084
4085 /// The confirmation tag to send back in the third step. `None` before a
4086 /// successful verification.
4087 #[must_use]
4088 pub fn confirmation(&self) -> Option<Vec<u8>> {
4089 self.confirmation
4090 .lock()
4091 .unwrap_or_else(std::sync::PoisonError::into_inner)
4092 .map(|tag| tag.to_vec())
4093 }
4094}
4095
4096/// The responder: holds the master key and proves it to the verifier.
4097#[derive(uniffi::Object)]
4098pub struct FfiResponder {
4099 /// The core responder state.
4100 inner: std::sync::Mutex<auth::Responder>,
4101}
4102
4103#[uniffi::export]
4104impl FfiResponder {
4105 /// Accepts a challenge sent by the other side.
4106 ///
4107 /// # Errors
4108 ///
4109 /// Returns [`Error::AuthMessageMalformed`] when the challenge is malformed
4110 /// (length, version, or an ephemeral public key that is not on the curve).
4111 #[uniffi::constructor]
4112 pub fn accept(challenge: Vec<u8>) -> Result<Arc<Self>, FfiError> {
4113 Ok(Arc::new(Self {
4114 inner: std::sync::Mutex::new(auth::accept(&challenge)?),
4115 }))
4116 }
4117
4118 /// The device identity this device uses in this session (SEC1 uncompressed
4119 /// encoding, 65 bytes).
4120 #[must_use]
4121 pub fn identity(&self) -> Vec<u8> {
4122 self.lock().public_key().to_vec()
4123 }
4124
4125 /// Produces the response to hand to the verifier.
4126 ///
4127 /// Signing goes through [`FfiDeviceKeys::sign`] and **receives the raw
4128 /// message**; the host-side implementation must not hash it first, since the
4129 /// core verifies with the standard ECDSA hash mode and hashing twice makes
4130 /// every verification fail.
4131 pub fn respond(
4132 &self,
4133 vault: Arc<FfiVault>,
4134 label: String,
4135 keys: Arc<dyn FfiDeviceKeys>,
4136 ) -> Result<Vec<u8>, FfiError> {
4137 check_len("label", &label, MAX_LABEL_CHARS)?;
4138 let vault = vault.lock();
4139 let responder = self.lock();
4140 let mut keys = ForeignDeviceKeys(keys);
4141 let device = keys.get_or_create(&label)?;
4142 let response =
4143 responder.respond(vault.master_key(), device.as_sec1_bytes(), |message| {
4144 keys.sign(&label, message)
4145 })?;
4146 Ok(response)
4147 }
4148
4149 /// Verifies the confirmation tag sent back by the verifier (the third step).
4150 ///
4151 /// Returns `true` on success. **This step must not be skipped**: it proves
4152 /// that the other side can actually compute the session key rather than only
4153 /// forwarding a challenge.
4154 ///
4155 /// # Errors
4156 ///
4157 /// Returns [`Error::AuthProofFailed`] when the tag does not match or the
4158 /// session identifier does not match, and [`Error::AuthMessageMalformed`]
4159 /// when the tag length is wrong.
4160 pub fn confirm(
4161 &self,
4162 vault: Arc<FfiVault>,
4163 session_id: Vec<u8>,
4164 confirmation: Vec<u8>,
4165 ) -> Result<bool, FfiError> {
4166 let session_id: [u8; AUTH_SESSION_ID_LEN] =
4167 session_id
4168 .as_slice()
4169 .try_into()
4170 .map_err(|_| Error::AuthMessageMalformed {
4171 reason: "wrong session identifier length",
4172 })?;
4173 let vault = vault.lock();
4174 let responder = self.lock();
4175 auth::confirm(vault.master_key(), &responder, &session_id, &confirmation)?;
4176 Ok(true)
4177 }
4178}
4179
4180impl FfiResponder {
4181 /// Takes the lock, ignoring poisoning.
4182 fn lock(&self) -> std::sync::MutexGuard<'_, auth::Responder> {
4183 self.inner
4184 .lock()
4185 .unwrap_or_else(std::sync::PoisonError::into_inner)
4186 }
4187}
4188
4189/// Builds [`KdfParams`] from the optional host-side values.
4190///
4191/// The values come from the host side (the user can adjust them in the user
4192/// interface) and, on import, from a file that is attacker controlled. The range
4193/// check lives in the core's `KdfParams::new`, which bounds both ends; this
4194/// function passes its error through rather than judging on its own, so there is a
4195/// single implementation of the check.
4196///
4197/// The host side passes `u64` rather than the core's `u32`: UniFFI maps `u32` to
4198/// an unsigned type on the Kotlin side, which Kotlin does not have natively. A
4199/// value above `u32` is rejected by the core's range check and is never silently
4200/// truncated.
4201fn kdf_params(
4202 memory_kib: Option<u64>,
4203 iterations: Option<u64>,
4204 parallelism: Option<u64>,
4205) -> Result<KdfParams, FfiError> {
4206 if memory_kib.is_none() && iterations.is_none() && parallelism.is_none() {
4207 return Ok(KdfParams::recommended());
4208 }
4209 let recommended = KdfParams::recommended();
4210 let narrow = |value: u64, what: &'static str| -> Result<u32, FfiError> {
4211 u32::try_from(value).map_err(|_| Error::RecoveryParametersInvalid { reason: what }.into())
4212 };
4213 Ok(KdfParams::new(
4214 narrow(
4215 memory_kib.unwrap_or(u64::from(recommended.memory_kib())),
4216 "memory limit is outside the representable range",
4217 )?,
4218 narrow(
4219 iterations.unwrap_or(u64::from(recommended.iterations())),
4220 "iteration count is outside the representable range",
4221 )?,
4222 narrow(
4223 parallelism.unwrap_or(u64::from(recommended.parallelism())),
4224 "parallelism is outside the representable range",
4225 )?,
4226 )?)
4227}
4228
4229// ── Synchronization: the channel contract and one session ────────────────
4230//
4231// The core owns the protocol: which payload goes first, what a merge does, what a conflict
4232// means. This half only moves data across the boundary:
4233//
4234// - the host side implements a channel (`FfiTransport`), which is where a real
4235// transport lives (today the text/QR code channel, later a LAN one);
4236// - `FfiSyncSession` drives one exchange against such a channel.
4237//
4238// Nothing here decides anything. A review of this section has one question: **has any
4239// protocol rule been copied in from the core?**
4240
4241/// How many parties take part in one sync.
4242///
4243/// Mirrors [`qualia2fa_core::transport::Roster`]. The distinction has to be declared by
4244/// the channel rather than guessed by the caller: the same code is two-party over QR codes
4245/// and may be multi-party for the same use over a LAN.
4246#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
4247pub enum FfiRoster {
4248 /// Exactly two parties. Transfer starts as soon as they connect.
4249 TwoParty,
4250 /// Three or more parties. The initiator decides when to start.
4251 MultiParty,
4252}
4253
4254impl From<FfiRoster> for Roster {
4255 fn from(value: FfiRoster) -> Self {
4256 match value {
4257 FfiRoster::TwoParty => Self::TwoParty,
4258 FfiRoster::MultiParty => Self::MultiParty,
4259 }
4260 }
4261}
4262
4263/// What a channel can do.
4264///
4265/// Mirrors [`qualia2fa_core::transport::TransportCapabilities`]; see it for the meaning of
4266/// each field. Two points matter at this boundary:
4267///
4268/// - **The values are a declaration, not a probe.** They must be available before the
4269/// exchange starts, so the caller can pick a mode before anything is displayed.
4270/// - **`max_message_len` is one message**, not one physical artifact: a channel that shows
4271/// several codes in sequence declares the sum over all of them, and how the bytes are cut
4272/// into frames is the channel's business.
4273#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Record)]
4274pub struct FfiTransportCapabilities {
4275 /// Roster of this channel.
4276 pub roster: FfiRoster,
4277 /// Per-message byte limit; `None` means a byte stream with no limit.
4278 pub max_message_len: Option<u64>,
4279 /// Largest QR version this channel may display, if it displays codes.
4280 pub max_qr_version: Option<u8>,
4281 /// Whether this channel may be used only once (QR codes are).
4282 pub single_use: bool,
4283 /// Whether this channel can carry a whole payload, or only link establishment.
4284 pub can_carry_payload: bool,
4285}
4286
4287impl From<FfiTransportCapabilities> for TransportCapabilities {
4288 fn from(value: FfiTransportCapabilities) -> Self {
4289 Self {
4290 roster: value.roster.into(),
4291 // A limit wider than this platform can address is treated as the largest one
4292 // it can: the direction is safe (a channel never gets a limit larger than it
4293 // declared) and the value is far beyond any real message.
4294 max_message_len: value
4295 .max_message_len
4296 .map(|len| usize::try_from(len).unwrap_or(usize::MAX)),
4297 max_qr_version: value.max_qr_version,
4298 single_use: value.single_use,
4299 can_carry_payload: value.can_carry_payload,
4300 }
4301 }
4302}
4303
4304/// The recipient of one message.
4305///
4306/// Mirrors [`qualia2fa_core::transport::Recipient`]. The recipient is an explicit parameter
4307/// rather than left implicit: on a channel with several peers, "forward this to one peer"
4308/// and "broadcast" are two different actions.
4309#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
4310pub enum FfiRecipient {
4311 /// Send to this peer only: a 64-character hexadecimal device identifier.
4312 One {
4313 /// Device identifier, hexadecimal.
4314 device_id: String,
4315 },
4316 /// Send to every peer of the current session.
4317 All,
4318}
4319
4320impl TryFrom<FfiRecipient> for Recipient {
4321 type Error = FfiError;
4322
4323 fn try_from(value: FfiRecipient) -> Result<Self, FfiError> {
4324 Ok(match value {
4325 FfiRecipient::One { device_id } => Self::One(parse_device_id(&device_id)?),
4326 FfiRecipient::All => Self::All,
4327 })
4328 }
4329}
4330
4331/// One message taken off a channel, together with the sender the channel claims.
4332///
4333/// Mirrors the return value of [`qualia2fa_core::transport::Transport::receive`]. The
4334/// `sender` is the **channel's claim**, and the core compares it with the sender inside the
4335/// payload: the payload's own field proves only that nobody changed it, while "who actually
4336/// handed this over" is something only the channel knows.
4337#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
4338pub struct FfiReceivedMessage {
4339 /// The sending device as the channel reports it, hexadecimal.
4340 pub sender: String,
4341 /// The message bytes.
4342 pub message: Vec<u8>,
4343}
4344
4345/// A channel that can move sync payloads.
4346///
4347/// **Implemented by the host side**, and by this layer's own [`FfiLanChannel`]: a session
4348/// takes either and cannot tell them apart.
4349///
4350/// Corresponds to [`qualia2fa_core::transport::Transport`]. An implementation forwards to a
4351/// real transport, **with no decisions of its own**: every protocol rule (what the bytes
4352/// mean, whether a payload was already applied, whether a sender is acceptable) lives in the
4353/// core.
4354///
4355/// # Contract the implementation must satisfy
4356///
4357/// 1. **`receive` must report the sender truthfully**, not just hand back bytes. On a
4358/// broadcast channel any device can replay a payload someone else sent, so "the payload
4359/// says it is from B" and "C actually sent it" can hold at the same time. The core turns
4360/// the comparison into a hard check, so misreporting the sender only makes the sync fail.
4361/// 2. **`send` must respect [`FfiRecipient`]**; `One` must not degrade into a broadcast.
4362/// 3. **`receive` must not block indefinitely.** With no message available it returns
4363/// `Ok(None)`, which the core reports as "no session found"; how long to wait is the
4364/// host side's decision, because the core has no clock.
4365/// 4. **A `single_use` channel must fail its second use** with
4366/// [`FfiError::Qr`]`{ kind: "transport_already_used" }`.
4367/// 5. **Device identifiers are 64 hexadecimal characters**: the 32-byte device fingerprint
4368/// (`qualia_dik::DeviceId`), the same value the pairing flow shows as a device identity.
4369#[uniffi::export(with_foreign)]
4370pub trait FfiTransport: Send + Sync {
4371 /// What this channel can do.
4372 fn capabilities(&self) -> FfiTransportCapabilities;
4373
4374 /// This device's identifier, hexadecimal.
4375 ///
4376 /// It fills in the `sender` of outgoing payloads, so both sides of an exchange agree on
4377 /// who sent what.
4378 fn local_device(&self) -> String;
4379
4380 /// Sends one message.
4381 fn send(&self, to: FfiRecipient, message: Vec<u8>) -> Result<(), FfiError>;
4382
4383 /// Takes one message off the channel.
4384 ///
4385 /// `Ok(None)` means "nothing available right now", which is a normal state and not an
4386 /// error; `Err` means the channel itself failed.
4387 fn receive(&self) -> Result<Option<FfiReceivedMessage>, FfiError>;
4388
4389 /// Every peer in the current session.
4390 ///
4391 /// Zero or one element on a two-party channel. This defines what `All` means.
4392 fn peers(&self) -> Result<Vec<String>, FfiError>;
4393}
4394
4395/// Forwarding implementation of the core `Transport`: bridges trait calls to the host-side
4396/// channel, after parsing this device's identifier **once** when the adapter is built.
4397///
4398/// Parsing up front is what makes it possible to satisfy the core trait at all: the core
4399/// asks for `local_device()` as a value that cannot fail, so an identifier that cannot be
4400/// parsed has to be reported before the session starts rather than in the middle of it.
4401struct ForeignTransport {
4402 /// The host-side channel.
4403 channel: Arc<dyn FfiTransport>,
4404 /// This device's identifier, parsed once.
4405 me: DeviceId,
4406}
4407
4408impl ForeignTransport {
4409 /// Builds the adapter, rejecting an unparsable local device identifier.
4410 fn new(channel: Arc<dyn FfiTransport>) -> Result<Self, FfiError> {
4411 let me = parse_device_id(&channel.local_device())?;
4412 Ok(Self { channel, me })
4413 }
4414}
4415
4416impl Transport for ForeignTransport {
4417 fn capabilities(&self) -> TransportCapabilities {
4418 self.channel.capabilities().into()
4419 }
4420
4421 fn local_device(&self) -> DeviceId {
4422 self.me
4423 }
4424
4425 fn send(&self, to: Recipient, message: &[u8]) -> Result<(), Error> {
4426 let to = match to {
4427 Recipient::One(id) => FfiRecipient::One {
4428 device_id: id.to_hex(),
4429 },
4430 Recipient::All => FfiRecipient::All,
4431 };
4432 self.channel
4433 .send(to, message.to_vec())
4434 .map_err(transport_error_to_core_error)
4435 }
4436
4437 fn receive(&self) -> Result<(DeviceId, Vec<u8>), Error> {
4438 match self
4439 .channel
4440 .receive()
4441 .map_err(transport_error_to_core_error)?
4442 {
4443 Some(received) => {
4444 let sender =
4445 parse_device_id(&received.sender).map_err(|_| Error::TransportFailed {
4446 reason: "the channel reported a device identifier that is not hexadecimal",
4447 })?;
4448 Ok((sender, received.message))
4449 }
4450 None => Err(Error::SyncNoSessionFound),
4451 }
4452 }
4453
4454 fn peers(&self) -> Result<Vec<DeviceId>, Error> {
4455 self.channel
4456 .peers()
4457 .map_err(transport_error_to_core_error)?
4458 .iter()
4459 .map(|id| {
4460 parse_device_id(id).map_err(|_| Error::TransportFailed {
4461 reason: "the channel reported a device identifier that is not hexadecimal",
4462 })
4463 })
4464 .collect()
4465 }
4466}
4467
4468/// Parses a device identifier: exactly 64 hexadecimal characters.
4469///
4470/// The form is the one every other device identifier at this boundary uses (see
4471/// [`FfiTransport`] point 5), so a value taken from a pairing screen can be compared with
4472/// one from a sync payload without conversion.
4473fn parse_device_id(text: &str) -> Result<DeviceId, FfiError> {
4474 let invalid = || FfiError::InvalidDeviceId {
4475 kind: "device_id".to_owned(),
4476 };
4477 let bytes = text.as_bytes();
4478 if bytes.len() != DEVICE_ID_LEN * 2 {
4479 return Err(invalid());
4480 }
4481
4482 let mut id = [0_u8; DEVICE_ID_LEN];
4483 for (index, slot) in id.iter_mut().enumerate() {
4484 let high = hex_digit(bytes[index * 2]).map_err(|_| invalid())?;
4485 let low = hex_digit(bytes[index * 2 + 1]).map_err(|_| invalid())?;
4486 *slot = (high << 4) | low;
4487 }
4488 Ok(DeviceId::from_bytes(id))
4489}
4490
4491/// Converts a failure reported by a channel back into a core error.
4492///
4493/// A channel reports failures as [`FfiError::Qr`] (the host side's own) or [`FfiError::Lan`]
4494/// (this layer's), and both carry a stable `kind` (see [`FfiError::kind`]). Two of them have a
4495/// core equivalent the protocol reacts to, and they are the two where the reaction differs:
4496///
4497/// - `transport_already_used`: a one-shot channel was used twice.
4498/// - `sync_no_session_found`: nothing to receive. A channel that prefers `Ok(None)` for
4499/// that state never produces it, but one that carries the core's wording does.
4500///
4501/// Everything else becomes [`Error::TransportFailed`] with an opaque reason: the channel's
4502/// own message belongs to the channel and is not part of the protocol layer.
4503fn transport_error_to_core_error(source: FfiError) -> Error {
4504 match &source {
4505 FfiError::Qr { kind, .. } | FfiError::Lan { kind, .. }
4506 if kind == "transport_already_used" =>
4507 {
4508 Error::TransportAlreadyUsed
4509 }
4510 FfiError::Qr { kind, .. } | FfiError::Lan { kind, .. }
4511 if kind == "sync_no_session_found" =>
4512 {
4513 Error::SyncNoSessionFound
4514 }
4515 _ => Error::TransportFailed {
4516 reason: "the channel reported a failure",
4517 },
4518 }
4519}
4520
4521/// What a payload asks the peer to do.
4522///
4523/// Mirrors [`qualia2fa_core::sync::PayloadKind`].
4524#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
4525pub enum FfiPayloadKind {
4526 /// The sender's full state: accept it and merge.
4527 Vault,
4528 /// A request: send me your full state.
4529 Request,
4530}
4531
4532impl From<FfiPayloadKind> for PayloadKind {
4533 fn from(value: FfiPayloadKind) -> Self {
4534 match value {
4535 FfiPayloadKind::Vault => Self::Vault,
4536 FfiPayloadKind::Request => Self::Request,
4537 }
4538 }
4539}
4540
4541/// Which route one share takes.
4542///
4543/// Mirrors [`qualia2fa_core::transport::SharePlan`]. "Does not fit" is a route, not a
4544/// failure: a request (which carries no records) goes out first and the payload is left to a
4545/// channel that can carry it.
4546#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
4547pub enum FfiSharePlan {
4548 /// The whole payload fits this channel.
4549 FullPayload {
4550 /// Size in bytes after sealing.
4551 len: u64,
4552 },
4553 /// It does not fit: a link on another channel is needed first.
4554 LinkEstablishment {
4555 /// Number of bytes the payload needs.
4556 needed: u64,
4557 /// Largest number of bytes this channel can carry.
4558 limit: u64,
4559 },
4560}
4561
4562impl From<SharePlan> for FfiSharePlan {
4563 fn from(value: SharePlan) -> Self {
4564 match value {
4565 SharePlan::FullPayload { len } => Self::FullPayload { len: len as u64 },
4566 SharePlan::LinkEstablishment { needed, limit } => Self::LinkEstablishment {
4567 needed: needed as u64,
4568 limit: limit as u64,
4569 },
4570 }
4571 }
4572}
4573
4574// ── Importing another authenticator's export ─────────────────────────
4575
4576/// An export format the host side can name.
4577///
4578/// [`detect_import_format`] answers the same question from the content, which is what a file
4579/// the user picked needs: the extension is a hint, not evidence.
4580#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
4581pub enum FfiImportFormat {
4582 /// The JSON export, plaintext or encrypted.
4583 Json,
4584 /// The HTML export: one table row per entry.
4585 Html,
4586 /// The text export: one URI per line.
4587 Text,
4588}
4589
4590impl From<FfiImportFormat> for qualia2fa_core::import::ImportFormat {
4591 fn from(value: FfiImportFormat) -> Self {
4592 match value {
4593 FfiImportFormat::Json => Self::Json,
4594 FfiImportFormat::Html => Self::Html,
4595 FfiImportFormat::Text => Self::Text,
4596 }
4597 }
4598}
4599
4600/// Names one field in which an imported entry differs from the one the vault holds.
4601///
4602/// # Stability contract
4603///
4604/// New values may be added; an existing one must not be renamed or change meaning, and a client
4605/// needs a fallback branch. Values: `name`, `issuer`, `account`, `note`, `algorithm`, `digits`,
4606/// `kind` (the kind carries the step, the counter and any PIN).
4607#[must_use]
4608fn import_difference_name(difference: ImportDifference) -> &'static str {
4609 match difference {
4610 ImportDifference::Name => "name",
4611 ImportDifference::Issuer => "issuer",
4612 ImportDifference::Account => "account",
4613 ImportDifference::Note => "note",
4614 ImportDifference::Algorithm => "algorithm",
4615 ImportDifference::Digits => "digits",
4616 ImportDifference::Kind => "kind",
4617 }
4618}
4619
4620/// Formats a sixteen-byte identifier the way the host side names entries and groups.
4621#[must_use]
4622fn hex_id(bytes: &[u8; 16]) -> String {
4623 let mut out = String::with_capacity(32);
4624 for byte in bytes {
4625 out.push_str(&format!("{byte:02x}"));
4626 }
4627 out
4628}
4629
4630/// One entry an export holds, as the preview shows it.
4631///
4632/// **No secret and no PIN cross here.** The preview answers "what is in this file?", and a name, a
4633/// service, an account and the shape of the code answer it; the key material stays in Rust, where
4634/// applying the import reads it again.
4635#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
4636pub struct FfiImportedEntry {
4637 /// Where the entry was in the export, counting from 1.
4638 ///
4639 /// This is the handle a decision names: positions are unique within one export, and the entry
4640 /// has no identifier yet.
4641 pub position: u64,
4642 /// Display name.
4643 pub name: String,
4644 /// Issuer (service name), if any.
4645 pub issuer: Option<String>,
4646 /// Account name.
4647 pub account: String,
4648 /// Hash algorithm the code uses.
4649 pub algorithm: FfiAlgorithm,
4650 /// Number of digits in the code.
4651 pub digits: FfiDigits,
4652 /// The kind, without the PIN (see [`FfiEntryKind`]).
4653 pub kind: FfiEntryKind,
4654 /// Whether the export marked it a favourite.
4655 ///
4656 /// Not a field of the entry: an import turns it into **order**, placing favourites before the
4657 /// rest within their group.
4658 pub favorite: bool,
4659 /// Whether the export carried an icon, which this version drops.
4660 pub icon_dropped: bool,
4661 /// Whether the time step had to be assumed, because the format does not carry one.
4662 pub period_assumed: bool,
4663}
4664
4665/// One entry of an export that could not be read.
4666#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
4667pub struct FfiImportProblem {
4668 /// Where in the export it was, counting from 1.
4669 pub position: u64,
4670 /// Why it was skipped, in a phrase meant for the user.
4671 ///
4672 /// Never carries a value from the file: a problem list ends up in logs and screenshots.
4673 pub reason: String,
4674}
4675
4676/// A group the export defined.
4677#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
4678pub struct FfiImportedGroup {
4679 /// The group's identifier, 32 hexadecimal characters, the same on every device that imports
4680 /// this file.
4681 pub id: String,
4682 /// The name the export gave it.
4683 pub name: String,
4684}
4685
4686/// What was dropped or assumed across a whole export.
4687///
4688/// Counts rather than a list: the interface says this once, in one sentence, instead of
4689/// interrupting per entry.
4690#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Record)]
4691pub struct FfiDroppedSummary {
4692 /// Entries whose icon was dropped.
4693 pub icons: u64,
4694 /// Entries whose time step was assumed.
4695 pub periods_assumed: u64,
4696 /// Entries skipped because the same export listed them twice.
4697 pub duplicates: u64,
4698}
4699
4700/// What the vault already holds that relates to one imported entry.
4701#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
4702pub struct FfiImportMatch {
4703 /// Where the entry was in the export, counting from 1.
4704 pub position: u64,
4705 /// Whether the vault already holds this entry, in every field the user can see.
4706 pub identical: bool,
4707 /// Entries in the vault that use the same secret, as 32 hexadecimal characters each.
4708 pub existing: Vec<String>,
4709 /// The fields that differ from the closest match, as stable names.
4710 ///
4711 /// # Stability contract
4712 ///
4713 /// New values may be added; an existing one must not be renamed or change meaning, and a
4714 /// client needs a fallback branch. Values: `name`, `issuer`, `account`, `note`, `algorithm`,
4715 /// `digits`, `kind` (the kind carries the step, the counter and any PIN).
4716 pub differences: Vec<String>,
4717}
4718
4719/// What to do with one imported entry.
4720#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
4721pub enum FfiImportDecision {
4722 /// Add it, leaving whatever the vault holds alone.
4723 Add,
4724 /// Change nothing: the vault already holds it.
4725 Skip,
4726 /// Put the imported version in place of the entry with this identifier.
4727 ///
4728 /// The identifier stays, so the entry keeps its group and its place in the ordering.
4729 Replace {
4730 /// The entry to replace, as 32 hexadecimal characters.
4731 entry_id: String,
4732 },
4733 /// Add it and keep the existing entry as well.
4734 KeepBoth,
4735}
4736
4737/// One answer, keyed by the position the entry had in the export.
4738#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
4739pub struct FfiImportChoice {
4740 /// The position of the entry this answer is about.
4741 pub position: u64,
4742 /// What to do with it.
4743 pub decision: FfiImportDecision,
4744}
4745
4746/// What an applied import did.
4747#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
4748pub struct FfiImportApplied {
4749 /// Entries added.
4750 pub added: u64,
4751 /// Entries whose content was replaced.
4752 pub replaced: u64,
4753 /// Entries left alone because the vault already held them.
4754 pub skipped: u64,
4755 /// Groups created from the export, as 32 hexadecimal characters each.
4756 pub groups_created: Vec<String>,
4757 /// Groups the export named that the vault already had.
4758 pub groups_existing: Vec<String>,
4759}
4760
4761/// One export, read and waiting for a decision.
4762///
4763/// The parsed export lives here, in Rust, **with its secrets**: the preview the host side sees
4764/// holds none of them, and applying the import reads them from this object rather than from a
4765/// second copy the host would have to keep.
4766#[derive(uniffi::Object)]
4767pub struct FfiImport {
4768 /// The parsed export.
4769 report: qualia2fa_core::import::ImportReport,
4770}
4771
4772#[uniffi::export]
4773impl FfiImport {
4774 /// Reads an export without touching a vault.
4775 ///
4776 /// `password` is only looked at when the export is encrypted; `now` is the current Unix second
4777 /// supplied by the host side (this library reads no clock) and becomes the creation and
4778 /// modification time of every imported entry.
4779 ///
4780 /// # Errors
4781 ///
4782 /// A file-level failure: not the format it claims to be, larger than this layer reads, a
4783 /// version it does not read, or a password it needs or does not accept. An entry that cannot
4784 /// be read is **not** such a failure: it appears in [`FfiImport::problems`] and the rest of
4785 /// the file is imported.
4786 #[uniffi::constructor]
4787 pub fn preview(
4788 bytes: Vec<u8>,
4789 format: FfiImportFormat,
4790 password: Option<String>,
4791 now: u64,
4792 ) -> Result<Arc<Self>, FfiError> {
4793 flow::check_now(now)?;
4794 let report = qualia2fa_core::import::import(
4795 &bytes,
4796 format.into(),
4797 now,
4798 password.as_ref().map(String::as_bytes),
4799 )?;
4800 Ok(Arc::new(Self { report }))
4801 }
4802
4803 /// The entries that could be read, in the order the export listed them.
4804 #[must_use]
4805 pub fn entries(&self) -> Vec<FfiImportedEntry> {
4806 self.report
4807 .entries
4808 .iter()
4809 .map(|imported| FfiImportedEntry {
4810 position: imported.position as u64,
4811 name: imported.entry.name.clone(),
4812 issuer: imported.entry.issuer.clone(),
4813 account: imported.entry.account.clone(),
4814 algorithm: match imported.entry.algorithm {
4815 Algorithm::Sha1 => FfiAlgorithm::Sha1,
4816 Algorithm::Sha256 => FfiAlgorithm::Sha256,
4817 Algorithm::Sha512 => FfiAlgorithm::Sha512,
4818 },
4819 digits: imported.entry.digits.into(),
4820 kind: FfiEntryKind::from_core(&imported.entry.kind),
4821 favorite: imported.favorite,
4822 icon_dropped: imported
4823 .notes
4824 .contains(&qualia2fa_core::import::ImportNote::IconDropped),
4825 period_assumed: imported.notes.iter().any(|note| {
4826 matches!(
4827 note,
4828 qualia2fa_core::import::ImportNote::PeriodAssumed { .. }
4829 )
4830 }),
4831 })
4832 .collect()
4833 }
4834
4835 /// The entries that could not be read, each with its position and a reason.
4836 #[must_use]
4837 pub fn problems(&self) -> Vec<FfiImportProblem> {
4838 self.report
4839 .problems
4840 .iter()
4841 .map(|problem| FfiImportProblem {
4842 position: problem.position as u64,
4843 reason: problem.reason.clone(),
4844 })
4845 .collect()
4846 }
4847
4848 /// The groups the export defined.
4849 #[must_use]
4850 pub fn groups(&self) -> Vec<FfiImportedGroup> {
4851 self.report
4852 .groups
4853 .iter()
4854 .map(|group| FfiImportedGroup {
4855 id: hex_id(group.id.as_bytes()),
4856 name: group.name.clone(),
4857 })
4858 .collect()
4859 }
4860
4861 /// What was dropped or assumed across the whole export.
4862 #[must_use]
4863 pub fn dropped(&self) -> FfiDroppedSummary {
4864 FfiDroppedSummary {
4865 icons: self.report.dropped.icons as u64,
4866 periods_assumed: self.report.dropped.periods_assumed as u64,
4867 duplicates: self.report.dropped.duplicates as u64,
4868 }
4869 }
4870
4871 /// Compares every entry against a vault.
4872 ///
4873 /// The result is in the order of [`FfiImport::entries`]. An entry that is `identical` should
4874 /// be skipped without asking; one that is not, but has entries in `existing`, is the question
4875 /// the user answers with [`FfiImportChoice`].
4876 #[must_use]
4877 pub fn compare(&self, vault: Arc<FfiVault>) -> Vec<FfiImportMatch> {
4878 let vault = vault.lock();
4879 qualia2fa_core::import::apply::compare(&self.report, vault.vault())
4880 .into_iter()
4881 .map(|found| FfiImportMatch {
4882 position: found.position as u64,
4883 identical: found.identical,
4884 existing: found.existing.iter().map(ToString::to_string).collect(),
4885 differences: found
4886 .differences
4887 .into_iter()
4888 .map(|difference| import_difference_name(difference).to_owned())
4889 .collect(),
4890 })
4891 .collect()
4892 }
4893
4894 /// Applies the import to a vault.
4895 ///
4896 /// `choices` holds the answers for the entries the user was asked about; an entry with no
4897 /// answer is added, unless the vault already holds it exactly, in which case it is skipped.
4898 /// **Nothing is ever overwritten without an answer.**
4899 ///
4900 /// # Errors
4901 ///
4902 /// The core's error when an answer names an entry the vault no longer holds
4903 /// (`vault_entry_not_found`), and its errors for a name that is too long or an identifier that
4904 /// is taken.
4905 pub fn apply(
4906 &self,
4907 vault: Arc<FfiVault>,
4908 choices: Vec<FfiImportChoice>,
4909 ) -> Result<FfiImportApplied, FfiError> {
4910 let mut answers: HashMap<usize, ImportChoice> = HashMap::new();
4911 for choice in choices {
4912 let decision = match choice.decision {
4913 FfiImportDecision::Add => ImportChoice::Add,
4914 FfiImportDecision::Skip => ImportChoice::Skip,
4915 FfiImportDecision::KeepBoth => ImportChoice::KeepBoth,
4916 FfiImportDecision::Replace { entry_id } => {
4917 ImportChoice::Replace(parse_entry_id(&entry_id)?)
4918 }
4919 };
4920 answers.insert(choice.position as usize, decision);
4921 }
4922
4923 let mut vault = vault.lock();
4924 let applied =
4925 qualia2fa_core::import::apply::apply(vault.vault_mut(), &self.report, &answers)?;
4926 Ok(FfiImportApplied {
4927 added: applied.added.len() as u64,
4928 replaced: applied.replaced.len() as u64,
4929 skipped: applied.skipped as u64,
4930 groups_created: applied
4931 .groups_created
4932 .iter()
4933 .map(|id| hex_id(id.as_bytes()))
4934 .collect(),
4935 groups_existing: applied
4936 .groups_existing
4937 .iter()
4938 .map(|id| hex_id(id.as_bytes()))
4939 .collect(),
4940 })
4941 }
4942}
4943
4944/// Guesses the format of an export from its content.
4945///
4946/// Returns `None` when it is not recognisable; the host side should then ask the user, which costs
4947/// one question and avoids reading a file as the wrong format.
4948///
4949/// A **free function** rather than a method on [`FfiImport`]: it answers a question about some
4950/// bytes rather than building the object, and UniFFI 0.29 exports neither an associated function
4951/// nor a constructor that returns something other than `Self`.
4952#[uniffi::export]
4953#[must_use]
4954pub fn detect_import_format(bytes: Vec<u8>) -> Option<FfiImportFormat> {
4955 match qualia2fa_core::import::ImportFormat::detect(&bytes)? {
4956 qualia2fa_core::import::ImportFormat::Json => Some(FfiImportFormat::Json),
4957 qualia2fa_core::import::ImportFormat::Html => Some(FfiImportFormat::Html),
4958 qualia2fa_core::import::ImportFormat::Text => Some(FfiImportFormat::Text),
4959 }
4960}
4961// ── Writing an export ────────────────────────────────────────────────
4962
4963/// An export format the host side can choose.
4964///
4965/// The six are the interface's numbered choices, in the order the interface offers them: the two
4966/// `.q2fa` containers first, then the four shapes another authenticator reads.
4967///
4968/// # They are not equally safe, and not equally complete
4969///
4970/// Only the two encrypted forms protect anything. The plain vault container is the format that
4971/// restores an account on a clean device, and it does so because the **master key is inside it in
4972/// clear** - the interface has to say that more strongly than it says anything about the other
4973/// files. [`FfiExportFormat::EncryptedVault`] and [`FfiExportFormat::EncryptedJson`] ask for a
4974/// password; the other four hold every secret as it stands.
4975#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
4976pub enum FfiExportFormat {
4977 /// A plain `.q2fa`: the master key in clear plus the whole vault ciphertext.
4978 Vault,
4979 /// A passphrase-protected `.q2fa`: the master key wrapped by the core recovery format.
4980 EncryptedVault,
4981 /// One `otpauth://` URI per line. Carries no name, no note and no group.
4982 Text,
4983 /// The JSON export: every field, the flat groups and the icons included.
4984 Json,
4985 /// The JSON export, locked with a password.
4986 EncryptedJson,
4987 /// The HTML export: a table with a QR code drawn for every entry.
4988 ///
4989 /// The code is what makes this format usable one account at a time, and it is also why the
4990 /// file is as sensitive as the vault: each code holds an entry's URI with its secret.
4991 Html,
4992}
4993
4994impl From<FfiExportFormat> for qualia2fa_core::export::ExportFormat {
4995 fn from(value: FfiExportFormat) -> Self {
4996 match value {
4997 FfiExportFormat::Vault => Self::Vault,
4998 FfiExportFormat::EncryptedVault => Self::EncryptedVault,
4999 FfiExportFormat::Text => Self::Text,
5000 FfiExportFormat::Json => Self::Json,
5001 FfiExportFormat::EncryptedJson => Self::EncryptedJson,
5002 FfiExportFormat::Html => Self::Html,
5003 }
5004 }
5005}
5006
5007/// How much of the group tree an export format carries.
5008///
5009/// A boolean would be a lie for the JSON forms: they keep the group **names and memberships** and
5010/// lose the nesting, and the interface must be able to say which it is.
5011#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
5012pub enum FfiGroupCarrying {
5013 /// The format has nowhere to put a group.
5014 None,
5015 /// The format keeps the named groups as one flat level, without the nesting.
5016 FlatGroups,
5017 /// The format keeps the whole tree: parents, nesting and the mixed order.
5018 FullTree,
5019}
5020
5021impl From<qualia2fa_core::export::GroupCarrying> for FfiGroupCarrying {
5022 fn from(value: qualia2fa_core::export::GroupCarrying) -> Self {
5023 match value {
5024 qualia2fa_core::export::GroupCarrying::None => Self::None,
5025 qualia2fa_core::export::GroupCarrying::FlatGroups => Self::FlatGroups,
5026 qualia2fa_core::export::GroupCarrying::FullTree => Self::FullTree,
5027 }
5028 }
5029}
5030
5031/// Whether this format is written with a password.
5032///
5033/// Asked **before** the file is named, so that the password question appears where it belongs
5034/// instead of being discovered from an error after the user has chosen a location.
5035#[uniffi::export]
5036#[must_use]
5037pub fn export_format_needs_password(format: FfiExportFormat) -> bool {
5038 qualia2fa_core::export::ExportFormat::from(format).needs_password()
5039}
5040
5041/// The file name extension for this format, without the dot.
5042///
5043/// Both JSON forms share one, and so do both `.q2fa` forms: they are the same file with and
5044/// without a password, and a reader tells them apart by the magic or by the `db` field.
5045#[uniffi::export]
5046#[must_use]
5047pub fn export_format_extension(format: FfiExportFormat) -> String {
5048 qualia2fa_core::export::ExportFormat::from(format)
5049 .extension()
5050 .to_owned()
5051}
5052
5053/// Whether this format carries the groups **at all**.
5054///
5055/// Kept for callers that only need "does it keep my groups"; the interface should prefer
5056/// [`export_format_group_carrying`], which tells the flat JSON forms apart from the two `.q2fa`
5057/// containers.
5058#[uniffi::export]
5059#[must_use]
5060pub fn export_format_carries_groups(format: FfiExportFormat) -> bool {
5061 qualia2fa_core::export::ExportFormat::from(format).carries_groups()
5062}
5063
5064/// How much of the group tree this format carries.
5065#[uniffi::export]
5066#[must_use]
5067pub fn export_format_group_carrying(format: FfiExportFormat) -> FfiGroupCarrying {
5068 qualia2fa_core::export::ExportFormat::from(format)
5069 .group_carrying()
5070 .into()
5071}
5072
5073/// The deepest a named group may be nested; the top group is depth zero.
5074///
5075/// The interface uses it to refuse a drop it cannot draw, and the vault enforces the same value.
5076#[uniffi::export]
5077#[must_use]
5078pub fn max_group_depth() -> u64 {
5079 u64::try_from(qualia2fa_core::vault::Vault::MAX_GROUP_DEPTH).unwrap_or(u64::MAX)
5080}
5081
5082// ── Portable vault files ─────────────────────────────────────────────
5083
5084/// What a `.q2fa` file turned out to be, judged by its magic.
5085///
5086/// # It says nothing about the contents
5087///
5088/// A file can carry the right magic and still be damaged, and the encrypted form still needs its
5089/// passphrase. What this answers is what the **interface** needs before it asks anything: whether
5090/// to show a password field, whether the file is a full backup, or whether it is the old key-only
5091/// shape that can only reopen a vault this device already has.
5092#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
5093pub enum FfiPortableKind {
5094 /// The master key in clear plus the vault ciphertext.
5095 PlainVault,
5096 /// The passphrase-wrapped master key plus the vault ciphertext.
5097 EncryptedVault,
5098 /// The old key-only recovery file: no entries of its own.
5099 KeyOnly,
5100}
5101
5102impl From<qualia2fa_core::portable::PortableKind> for FfiPortableKind {
5103 fn from(value: qualia2fa_core::portable::PortableKind) -> Self {
5104 match value {
5105 qualia2fa_core::portable::PortableKind::PlainVault => Self::PlainVault,
5106 qualia2fa_core::portable::PortableKind::EncryptedVault => Self::EncryptedVault,
5107 qualia2fa_core::portable::PortableKind::KeyOnly => Self::KeyOnly,
5108 }
5109 }
5110}
5111
5112/// Recognizes a portable vault file without opening it.
5113///
5114/// `None` means the file is not one of the three portable shapes. In particular a **bare vault
5115/// file** (`Q2FAVLT\0`) answers `None`: it holds ciphertext but no master key, and the interface
5116/// should tell the user to export a vault backup instead of trying to import it.
5117#[uniffi::export]
5118#[must_use]
5119pub fn detect_portable_file(file: Vec<u8>) -> Option<FfiPortableKind> {
5120 qualia2fa_core::portable::detect(&file).map(FfiPortableKind::from)
5121}
5122
5123/// What opening a portable vault file produced.
5124///
5125/// The two cases are kept apart rather than folded into "a vault or an error": an old key-only
5126/// file is a legitimate file, and the reason it cannot restore an account on its own is a
5127/// property of the file, not a failure.
5128#[derive(uniffi::Record)]
5129pub struct FfiPortableRestore {
5130 /// The restored vault, when the file carried one. Its master key is already stored.
5131 pub vault: Option<Arc<FfiVault>>,
5132 /// Whether the file was an old key-only recovery file.
5133 ///
5134 /// Nothing was written in that case. A caller that has a local vault may retry with
5135 /// [`import_recovery_key`] and open that vault with the restored key.
5136 pub key_only: bool,
5137}
5138
5139/// Restores an account from a portable `.q2fa` file.
5140///
5141/// # What it does
5142///
5143/// Splits the container, recovers the master key (from the file itself for the plain form, from
5144/// the passphrase for the encrypted one), **writes the key into the platform store**, and opens
5145/// the vault the container carries. If the vault does not open, the previous key is put back
5146/// before the error is returned: a damaged or mismatched file must not leave the device on a key
5147/// that opens nothing.
5148///
5149/// # Errors
5150///
5151/// - `portable_vault_file_without_key`: the file is a bare vault file.
5152/// - `portable_password_required`: the encrypted form was opened with no password.
5153/// - `portable_bad_magic`, `portable_unsupported_version`, `portable_truncated`,
5154/// `portable_length_mismatch`, `portable_too_large`: the container is malformed.
5155/// - `recovery_authentication_failed`: the passphrase is wrong, or the wrapped part was modified.
5156/// - `vault_decryption_failed`: the key does not open the vault the file carries; the previous
5157/// key has been put back.
5158#[uniffi::export]
5159pub fn open_portable_file(
5160 file: Vec<u8>,
5161 password: Option<String>,
5162 store: Arc<dyn FfiSecureStore>,
5163) -> Result<FfiPortableRestore, FfiError> {
5164 if let Some(password) = &password {
5165 check_len("passphrase", password, MAX_PASSPHRASE_CHARS)?;
5166 }
5167 let passphrase = SecretString::from(password.unwrap_or_default());
5168 let mut store = ForeignSecureStore(store);
5169 match flow::open_portable(&file, &passphrase, &mut store)? {
5170 flow::PortableRestore::Vault(vault_file) => Ok(FfiPortableRestore {
5171 vault: Some(FfiVault::wrap(vault_file)),
5172 key_only: false,
5173 }),
5174 flow::PortableRestore::KeyOnly => Ok(FfiPortableRestore {
5175 vault: None,
5176 key_only: true,
5177 }),
5178 }
5179}
5180
5181#[uniffi::export]
5182impl FfiVault {
5183 /// Writes the whole vault out in one of the export formats.
5184 ///
5185 /// The bytes are returned rather than written anywhere: this library does no I/O, so where
5186 /// the file goes is the host side's decision (the platform's own save dialog).
5187 ///
5188 /// # What crosses the boundary
5189 ///
5190 /// For [`FfiExportFormat::Vault`], **the master key in clear** beside the vault ciphertext:
5191 /// that is what lets the file restore an account on a clean device, and it is why the
5192 /// interface must warn more strongly about it than about the four formats another
5193 /// authenticator reads. [`FfiExportFormat::EncryptedVault`] is the same pair with the master
5194 /// key wrapped by a passphrase. The three plaintext external formats hold every secret in
5195 /// the vault as it stands; only the two encrypted forms protect anything, and an empty
5196 /// password is refused rather than accepted.
5197 ///
5198 /// # The lock is held for the whole call
5199 ///
5200 /// Deliberately, and unlike the entry methods: an export reads the entire vault, so it needs
5201 /// one consistent view of it, and releasing the lock would mean copying the vault first -
5202 /// a second copy of every secret in memory for as long as that copy lives. The cost is that
5203 /// a concurrent refresh waits, and a user who has just asked for an export is not editing
5204 /// entries at that moment.
5205 ///
5206 /// # Errors
5207 ///
5208 /// The core's kind reaches the host side: `export_password_required` when either encrypted
5209 /// form was chosen with no password, `export_password_empty`, `uri_account_contains_colon`
5210 /// when an entry's account cannot be written into a URI, `export_qr_too_long` when one
5211 /// entry's URI does not fit the QR code of the HTML export, `export_too_large` when the
5212 /// vault does not fit a `.q2fa` container, and `randomness_unavailable` when the OS random
5213 /// source is unavailable (the salt, the nonces and the master key come from it).
5214 pub fn export(
5215 &self,
5216 format: FfiExportFormat,
5217 password: Option<String>,
5218 ) -> Result<Vec<u8>, FfiError> {
5219 let password = password.map(String::into_bytes);
5220 let vault = self.lock();
5221 let bytes = qualia2fa_core::export::export(&vault, format.into(), password.as_deref())?;
5222 Ok(bytes)
5223 }
5224}
5225
5226/// What the user decided about one conflict.
5227///
5228/// Mirrors [`qualia2fa_core::sync::Decision`].
5229#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
5230pub enum FfiDecision {
5231 /// Keep the state of this device; the peer's version is discarded.
5232 KeepLocal,
5233 /// Take the peer's version, discarding the state of this device.
5234 TakeIncoming,
5235}
5236
5237impl From<FfiDecision> for Decision {
5238 fn from(value: FfiDecision) -> Self {
5239 match value {
5240 FfiDecision::KeepLocal => Self::KeepLocal,
5241 FfiDecision::TakeIncoming => Self::TakeIncoming,
5242 }
5243 }
5244}
5245
5246/// One side of a conflict, as far as it can be shown.
5247///
5248/// **No secret crosses here.** A conflict is displayed so the user can tell two versions
5249/// apart, and a name, a service, an account and a note are what that needs; the seed would
5250/// only add a second copy of key material in the host process, where it cannot be erased.
5251/// The decision itself is applied inside Rust, from the copy the conflict already holds.
5252#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
5253pub struct FfiConflictSide {
5254 /// Display name.
5255 pub name: String,
5256 /// Issuer (service name), if any.
5257 pub issuer: Option<String>,
5258 /// Account name.
5259 pub account: String,
5260 /// Note.
5261 pub note: String,
5262 /// Last modification time of this version (Unix seconds, from the device that wrote it).
5263 pub updated_at: u64,
5264}
5265
5266impl FfiConflictSide {
5267 /// Reads the displayable fields of one entry.
5268 fn from_entry(entry: &Entry) -> Self {
5269 Self {
5270 name: entry.name.clone(),
5271 issuer: entry.issuer.clone(),
5272 account: entry.account.clone(),
5273 note: entry.note.clone(),
5274 updated_at: entry.updated_at,
5275 }
5276 }
5277}
5278
5279/// Two arrangements of the same group, for the user to choose between.
5280///
5281/// A group is a **sequence**, so two devices can disagree about it without disagreeing about a
5282/// single entry: each added its own children, or one reordered what both have. Neither side is
5283/// wrong, which is why this is the user's choice and not a merge rule.
5284///
5285/// The two lists hold the whole child arrangement - entries and child groups mixed - in display
5286/// order. A reference the vault does not hold is one the peer knows and this device does not; the
5287/// host side can look up the names of the rest in its own group list. The identifiers are the
5288/// same on both sides for every child both devices hold, so what is being chosen is an
5289/// **arrangement**, not a set.
5290///
5291/// # Moving a group can produce two of these
5292///
5293/// Moving a group between parents rewrites the old parent's list and the new parent's list, and
5294/// each is a separate record, so each can be reported. They are independent conflicts about two
5295/// different groups; applying either answer keeps the tree valid, because a child has only one
5296/// parent.
5297#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
5298pub struct FfiOrderingConflict {
5299 /// The group's name on this device.
5300 pub local_name: String,
5301 /// The group's name on the peer, which differs when the disagreement is a rename.
5302 pub incoming_name: String,
5303 /// Whether this is the top group, which entries belong to when they belong to no named one.
5304 ///
5305 /// Such a group has no name, and the two name fields are empty.
5306 pub top: bool,
5307 /// This device's arrangement, in order.
5308 pub local: Vec<FfiGroupChild>,
5309 /// The peer's arrangement, in order.
5310 pub incoming: Vec<FfiGroupChild>,
5311}
5312
5313/// One disagreement a human has to settle.
5314///
5315/// The fields are **normalized**, which is not how the core stores them: there,
5316/// `DeletionConflict` keeps the peer's record in one field and the local tombstone in
5317/// another, because that is what has to be written back. Here:
5318///
5319/// - `local` is the live entry **this device** holds, if it holds one;
5320/// - `incoming` is the live entry the **peer** sent, if it sent one.
5321///
5322/// A tombstone has nothing to display, so the side that deleted is `None`. Which side
5323/// deleted is what `kind` says.
5324#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
5325pub struct FfiConflict {
5326 /// The entry in disagreement, as 32 hexadecimal characters.
5327 pub entry_id: String,
5328 /// Stable identifier of the shape; see [`conflict_kind`].
5329 pub kind: String,
5330 /// The local version, when this device holds a live entry.
5331 pub local: Option<FfiConflictSide>,
5332 /// The peer's version, when the peer sent a live entry.
5333 pub incoming: Option<FfiConflictSide>,
5334 /// Whether the local version is the later one.
5335 ///
5336 /// A **hint for the user interface**, not a basis for a decision: the peer fills in its
5337 /// own timestamps. The core never picks a side from it, and neither does the host side.
5338 pub local_is_newer: bool,
5339 /// The two arrangements in disagreement, when the conflict is about one.
5340 ///
5341 /// When this is `Some`, the two sides above are `None` and `entry_id` is a **group**
5342 /// identifier rather than an entry one: that is the identifier the decision names. The core
5343 /// reuses one field for both because a decision about an ordering is a decision about a
5344 /// whole group, and the conflict carries whichever identifier the decision needs.
5345 pub ordering: Option<FfiOrderingConflict>,
5346}
5347
5348impl FfiConflict {
5349 /// Converts one conflict for display, normalizing the two sides.
5350 ///
5351 /// The local side of `peer_deleted` is **read from the vault** rather than taken from
5352 /// the conflict: the core leaves it out on purpose (it is already stored, and copying
5353 /// it would duplicate key material for a screen that only shows names), so the vault is
5354 /// where that version lives.
5355 fn from_core(conflict: &Conflict, vault: &Vault) -> Self {
5356 let mut ordering = None;
5357 let (local, incoming, local_is_newer) = match &conflict.kind {
5358 ConflictKind::OrderingMismatch { local, incoming } => {
5359 ordering = Some(FfiOrderingConflict {
5360 local_name: local.name.clone(),
5361 incoming_name: incoming.name.clone(),
5362 top: conflict.group_id().is_some_and(|id| id.is_top()),
5363 local: local
5364 .children
5365 .iter()
5366 .map(FfiGroupChild::from_core)
5367 .collect(),
5368 incoming: incoming
5369 .children
5370 .iter()
5371 .map(FfiGroupChild::from_core)
5372 .collect(),
5373 });
5374 (None, None, false)
5375 }
5376 ConflictKind::EntryMismatch { local, incoming } => (
5377 Some(FfiConflictSide::from_entry(local)),
5378 Some(FfiConflictSide::from_entry(incoming)),
5379 false,
5380 ),
5381 ConflictKind::DeletionConflict {
5382 local,
5383 local_is_newer,
5384 ..
5385 } => (
5386 vault
5387 .get(&conflict.entry_id)
5388 .map(FfiConflictSide::from_entry),
5389 local
5390 .as_ref()
5391 .map(|entry| FfiConflictSide::from_entry(entry)),
5392 *local_is_newer,
5393 ),
5394 };
5395 Self {
5396 entry_id: conflict.entry_id.to_string(),
5397 kind: conflict_kind(conflict).to_owned(),
5398 local,
5399 incoming,
5400 local_is_newer,
5401 ordering,
5402 }
5403 }
5404}
5405
5406/// Names the shape of a conflict, in the form the host side branches on.
5407///
5408/// # Stability contract
5409///
5410/// These strings are part of the public interface: new values may be added, but an existing
5411/// one must not be renamed or change meaning. A client must have a fallback branch for
5412/// values it does not know, because a newer library may report a shape it has never seen.
5413///
5414/// | value | meaning | deciding |
5415/// |---|---|---|
5416/// | `entry_mismatch` | both sides hold a live entry with different content | either version |
5417/// | `peer_deleted` | the peer reports a deletion; this device holds a newer entry | keep the entry, or accept the deletion |
5418/// | `local_deleted` | this device reports a deletion; the peer still holds an entry | keep it deleted, or restore the peer's entry |
5419/// | `ordering_mismatch` | both devices arranged a group, differently | this device's arrangement, or the peer's |
5420#[must_use]
5421pub fn conflict_kind(conflict: &Conflict) -> &'static str {
5422 match &conflict.kind {
5423 ConflictKind::EntryMismatch { .. } => "entry_mismatch",
5424 ConflictKind::DeletionConflict { local: None, .. } => "peer_deleted",
5425 ConflictKind::DeletionConflict { local: Some(_), .. } => "local_deleted",
5426 ConflictKind::OrderingMismatch { .. } => "ordering_mismatch",
5427 }
5428}
5429
5430/// The result of one receive.
5431///
5432/// Mirrors [`qualia2fa_core::transport::SyncOutcome`], with the counters as plain numbers so
5433/// the host side can summarize a sync without looking at the conflicts.
5434#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
5435pub struct FfiSyncOutcome {
5436 /// Identifier of the payload just processed, hexadecimal.
5437 pub sync_id: String,
5438 /// Number of entries added.
5439 pub added: u64,
5440 /// Number of tombstones added.
5441 pub tombstones_added: u64,
5442 /// Number of deletions applied.
5443 pub deletions_applied: u64,
5444 /// Number of records whose content was already identical.
5445 pub unchanged: u64,
5446 /// Whether this sync changed nothing at all.
5447 ///
5448 /// Reported by the core rather than computed here: "nothing changed" and "everything
5449 /// was already there" are one condition, and assembling it at a second place is how the
5450 /// two stop agreeing.
5451 pub made_no_change: bool,
5452 /// Whether conflicts are waiting for a decision.
5453 pub needs_adjudication: bool,
5454 /// The conflicts themselves, to show the user.
5455 pub conflicts: Vec<FfiConflict>,
5456}
5457
5458/// One synchronization session against one channel.
5459///
5460/// # It borrows the vault, so the vault must not be reassigned while it is used
5461///
5462/// The session works on the [`FfiVault`] object it was opened with. The host side keeps that
5463/// object alive; the merge writes into it, and the host side **must persist it afterwards**
5464/// ([`FfiVault::serialize`]) or the merged records are lost when the process ends.
5465///
5466/// # The replay log lives in this object
5467///
5468/// The core keeps a log of processed payload identifiers so that the same payload is not
5469/// decrypted and merged again. It is not persisted, for the same reason the pairing session
5470/// is not: a log written to disk is another store to keep in step with the vault, and the
5471/// code channel is out-of-band and one-shot, so the practical exposure is a user who
5472/// re-applies a code they already applied — which merges to the same content. A long-lived
5473/// broadcast channel (a LAN) will need the log to survive a restart, and that is the point
5474/// at which it becomes worth persisting.
5475///
5476/// # Only one exchange at a time
5477///
5478/// A session accepts payloads from **any** peer: the channel it is used with has confirmed
5479/// the peer out of band (the user carried the code). A channel that carries several peers
5480/// has to restrict them instead, and that entry point is not exposed yet, because no such
5481/// channel exists.
5482#[derive(uniffi::Object)]
5483pub struct FfiSyncSession {
5484 /// The vault being synchronized: master key and records both stay on this side.
5485 vault: Arc<FfiVault>,
5486 /// Identifiers of the payloads already processed.
5487 seen: std::sync::Mutex<SeenSyncIds>,
5488 /// Conflicts of the last receive that are waiting for a decision.
5489 pending: std::sync::Mutex<Vec<Conflict>>,
5490}
5491
5492#[uniffi::export]
5493impl FfiSyncSession {
5494 /// Opens a session on an unlocked vault.
5495 #[uniffi::constructor]
5496 pub fn open(vault: Arc<FfiVault>) -> Arc<Self> {
5497 Arc::new(Self {
5498 vault,
5499 seen: std::sync::Mutex::new(SeenSyncIds::new()),
5500 pending: std::sync::Mutex::new(Vec::new()),
5501 })
5502 }
5503
5504 /// Asks which route sharing this vault's state takes on `transport`.
5505 ///
5506 /// Sends nothing and displays nothing: the answer is needed **before** a code is shown,
5507 /// and [`FfiSyncSession::send`] seals and sends in one step, leaving no chance to look
5508 /// at the size first.
5509 pub fn plan_share(&self, transport: Arc<dyn FfiTransport>) -> Result<FfiSharePlan, FfiError> {
5510 let transport = ForeignTransport::new(transport)?;
5511 let mut vault = self.vault.lock();
5512 let mut seen = self.lock_seen();
5513 let (master, contents) = vault.split_mut();
5514 let session = SyncSession::open(master, contents, &mut seen);
5515 Ok(session.plan_share(&transport)?.into())
5516 }
5517
5518 /// Seals this vault's state (or a request for the peer's) and sends it.
5519 ///
5520 /// Returns the payload identifier, hexadecimal. The payload is **not** recorded in the
5521 /// replay log: that log holds what this device received, and recording an outgoing
5522 /// payload would make this device reject its own payload when a peer sends it back.
5523 pub fn send(
5524 &self,
5525 transport: Arc<dyn FfiTransport>,
5526 to: FfiRecipient,
5527 kind: FfiPayloadKind,
5528 ) -> Result<String, FfiError> {
5529 let transport = ForeignTransport::new(transport)?;
5530 let to = Recipient::try_from(to)?;
5531 let mut vault = self.vault.lock();
5532 let mut seen = self.lock_seen();
5533 let (master, contents) = vault.split_mut();
5534 let session = SyncSession::open(master, contents, &mut seen);
5535 Ok(to_hex(&session.send(&transport, to, kind.into())?))
5536 }
5537
5538 /// Receives one message and merges its records into the vault.
5539 ///
5540 /// The conflicts of this receive **replace** the ones still waiting from the previous
5541 /// one: a decision belongs to the merge that reported it, and carrying a conflict
5542 /// forward from an older merge would apply a decision to content the user is no longer
5543 /// looking at. The host side must decide before receiving again, or accept that those
5544 /// conflicts are dropped.
5545 ///
5546 /// # Errors
5547 ///
5548 /// Every rejected payload keeps its own error: `sync_no_session_found` when the channel
5549 /// had nothing, `sync_already_applied` for a payload that was already processed,
5550 /// `sync_sender_mismatch` when the envelope's sender and the payload's disagree, and the
5551 /// authentication failures of the payload itself. **Both the vault and the replay log are
5552 /// left unchanged** when it fails.
5553 pub fn receive(&self, transport: Arc<dyn FfiTransport>) -> Result<FfiSyncOutcome, FfiError> {
5554 let transport = ForeignTransport::new(transport)?;
5555
5556 let (record, conflicts) = {
5557 let mut vault = self.vault.lock();
5558 let mut seen = self.lock_seen();
5559 let (master, contents) = vault.split_mut();
5560 let mut session = SyncSession::open(master, contents, &mut seen);
5561 let outcome = session.receive(&transport)?;
5562
5563 let made_no_change = outcome.made_no_change();
5564 let needs_adjudication = outcome.needs_adjudication();
5565 let merge = outcome.merge;
5566 let record = FfiSyncOutcome {
5567 sync_id: to_hex(&outcome.sync_id),
5568 added: merge.added as u64,
5569 tombstones_added: merge.tombstones_added as u64,
5570 deletions_applied: merge.deletions_applied as u64,
5571 unchanged: merge.unchanged as u64,
5572 made_no_change,
5573 needs_adjudication,
5574 conflicts: merge
5575 .conflicts
5576 .iter()
5577 .map(|conflict| FfiConflict::from_core(conflict, vault.vault()))
5578 .collect(),
5579 };
5580 (record, merge.conflicts)
5581 };
5582
5583 *self.lock_pending() = conflicts;
5584 Ok(record)
5585 }
5586
5587 /// The conflicts waiting for a decision, in the order the merge reported them.
5588 #[must_use]
5589 pub fn pending_conflicts(&self) -> Vec<FfiConflict> {
5590 let vault = self.vault.lock();
5591 self.lock_pending()
5592 .iter()
5593 .map(|conflict| FfiConflict::from_core(conflict, vault.vault()))
5594 .collect()
5595 }
5596
5597 /// Applies one decision to the vault.
5598 ///
5599 /// `entry_id` names the conflict, which is the identifier the conflict record carries.
5600 /// An identifier rather than a position on purpose: a position shifts as soon as one
5601 /// conflict is decided, and a caller that kept the list it displayed would then decide
5602 /// the wrong entry.
5603 ///
5604 /// What each decision means for each shape is documented on
5605 /// [`qualia2fa_core::sync::Decision`]; the rules stay in the core.
5606 ///
5607 /// # Errors
5608 ///
5609 /// [`FfiError::UnknownConflict`] when nothing is waiting for that identifier, and the
5610 /// core's error when the conflict no longer describes the vault (the entry was deleted
5611 /// or re-created in the meantime). Nothing is written when the call fails, and the
5612 /// conflict stays pending.
5613 pub fn adjudicate(&self, entry_id: String, decision: FfiDecision) -> Result<(), FfiError> {
5614 let conflict = {
5615 let mut pending = self.lock_pending();
5616 let index = pending
5617 .iter()
5618 .position(|conflict| conflict.entry_id.to_string() == entry_id)
5619 .ok_or_else(|| FfiError::UnknownConflict {
5620 entry_id: entry_id.clone(),
5621 })?;
5622 pending.remove(index)
5623 };
5624
5625 let outcome = {
5626 let mut vault = self.vault.lock();
5627 adjudicate(vault.vault_mut(), &conflict, decision.into())
5628 };
5629 match outcome {
5630 Ok(_) => Ok(()),
5631 Err(error) => {
5632 // The decision did not apply, so the conflict is still open and has to stay
5633 // where the caller can see it.
5634 self.lock_pending().push(conflict);
5635 Err(error.into())
5636 }
5637 }
5638 }
5639}
5640
5641impl FfiSyncSession {
5642 /// Takes the replay-log lock, ignoring poisoning (see [`FfiVault::lock`]).
5643 fn lock_seen(&self) -> std::sync::MutexGuard<'_, SeenSyncIds> {
5644 self.seen
5645 .lock()
5646 .unwrap_or_else(std::sync::PoisonError::into_inner)
5647 }
5648
5649 /// Takes the pending-conflict lock, ignoring poisoning.
5650 ///
5651 /// **Lock order**: this lock is taken while the vault lock is held in
5652 /// [`FfiSyncSession::pending_conflicts`], so the vault always comes first.
5653 /// [`FfiSyncSession::adjudicate`] takes a conflict **out** of the list and releases this
5654 /// lock before it reaches for the vault, so the two orders never nest into a cycle.
5655 fn lock_pending(&self) -> std::sync::MutexGuard<'_, Vec<Conflict>> {
5656 self.pending
5657 .lock()
5658 .unwrap_or_else(std::sync::PoisonError::into_inner)
5659 }
5660}
5661
5662/// Lower-case hexadecimal form of a byte string.
5663///
5664/// Same form as every other identifier at this boundary; the core's own `Display` is used
5665/// where the type has one (`EntryId`), and this covers the raw byte strings that do not.
5666fn to_hex(bytes: &[u8]) -> String {
5667 let mut out = String::with_capacity(bytes.len() * 2);
5668 for byte in bytes {
5669 out.push(char::from_digit(u32::from(byte >> 4), 16).unwrap_or('0'));
5670 out.push(char::from_digit(u32::from(byte & 0x0F), 16).unwrap_or('0'));
5671 }
5672 out
5673}
5674
5675// ── The LAN channel: discovery, and an FfiTransport this layer owns ──────
5676//
5677// The code channel above is the host side's: the application implements [`FfiTransport`] over
5678// text the user carries from one device to the other. This half runs the other way — the
5679// channel is built here, out of `qualia-transport`'s iroh endpoint, and handed to the host
5680// side as an object it forwards to. A review of this section asks the same question the sync
5681// section asks: **has any protocol rule been copied in from the core?**
5682
5683/// One device the lobby has heard announcing itself on this network segment.
5684#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
5685pub struct FfiLanPeer {
5686 /// Device identifier, hexadecimal, the form every identifier uses at this boundary.
5687 pub device_id: String,
5688 /// The peer's connection record, as [`FfiLanChannel::remember_peer`] takes it.
5689 ///
5690 /// Carried as the transport's own bytes rather than as a string invented here: the record
5691 /// is versioned and checksummed there, and its fields are the endpoint public key and the
5692 /// port.
5693 pub record: Vec<u8>,
5694 /// The peer's address, as `ip:port`, or `None` when there was no source for it.
5695 ///
5696 /// **This is the only address a peer can be dialed at.** An announcement carries no address
5697 /// of its own, and an endpoint reports its own sockets as the wildcard address, so neither
5698 /// the peer nor this device can name one: whoever received the datagram is the only party
5699 /// that knows where it came from. The IP is that sender's; the port is the one the peer
5700 /// announced, because the datagram itself left the lobby's socket.
5701 pub socket: Option<String>,
5702 /// Whether the peer is still announcing itself (`false` after a `Bye`).
5703 pub here: bool,
5704}
5705
5706/// The LAN channel: one endpoint on this network segment, and the peers it sends to.
5707///
5708/// # It satisfies the same contract as a host-side channel
5709///
5710/// The five points in [`FfiTransport`]'s documentation hold here unchanged: `receive` reports
5711/// the sender truthfully, `send` respects the recipient, `receive` never blocks, the channel
5712/// is not single-use, and device identifiers are 64 hexadecimal characters. The difference is
5713/// **who owns it**: this object, rather than the application.
5714///
5715/// # What it declares
5716///
5717/// One message may be up to the transport's own cap, the channel may be reused, and it can
5718/// carry a whole payload. The roster is declared multi-party because the endpoint accepts a
5719/// connection from any device on the segment, even though one exchange involves two of them:
5720/// the declaration describes the channel, not the session.
5721///
5722/// # The peer table is kept here as well
5723///
5724/// [`FfiTransport::peers`] has to answer what `All` means, and the transport's own accessor
5725/// reports *connected* peers while `All` goes to *registered* ones. This layer therefore
5726/// mirrors the table it writes, through [`FfiLanChannel::remember_peer`] and
5727/// [`FfiLanChannel::forget_peer`] alone.
5728///
5729/// # Current state
5730///
5731/// The endpoint is bound to a system-chosen port and its key is generated per process; neither
5732/// is persisted. A peer that remembered an earlier address has to be told the current one,
5733/// which is what a lobby announcement or a code the user carries is for.
5734#[derive(uniffi::Object)]
5735pub struct FfiLanChannel {
5736 /// The endpoint and its own peer table.
5737 inner: IrohLan,
5738 /// This device's identifier, parsed once: the core asks for it as a value that cannot fail.
5739 me: DeviceId,
5740 /// The peers this channel sends to, in the order they were registered.
5741 peers: Mutex<Vec<DeviceId>>,
5742}
5743
5744#[uniffi::export]
5745impl FfiLanChannel {
5746 /// Opens an endpoint for this device, bound to a system-chosen port.
5747 ///
5748 /// The endpoint key is generated here and is **not** the device identity from the platform
5749 /// key store: it is the transport's own identity, proved by the QUIC handshake.
5750 ///
5751 /// # Errors
5752 ///
5753 /// [`FfiError::InvalidDeviceId`] when the identifier is not 64 hexadecimal characters;
5754 /// [`FfiError::Core`]`{ kind: "randomness_unavailable" }` when the endpoint key cannot be
5755 /// generated; [`FfiError::Lan`] when the endpoint cannot be built, which in practice means
5756 /// no free port or system policy blocking UDP.
5757 #[uniffi::constructor]
5758 pub fn open(device_id: String) -> Result<Arc<Self>, FfiError> {
5759 let me = parse_device_id(&device_id)?;
5760 let mut secret = [0_u8; ENDPOINT_KEY_LEN];
5761 qualia2fa_core::getrandom::fill(&mut secret).map_err(|_| FfiError::Core {
5762 detail: Error::RandomnessUnavailable.into(),
5763 })?;
5764
5765 Ok(Arc::new(Self {
5766 inner: IrohLan::open(me, EndpointKey::from_bytes(secret)).map_err(lan_error)?,
5767 me,
5768 peers: Mutex::new(Vec::new()),
5769 }))
5770 }
5771
5772 /// This endpoint's connection record: what a peer needs in order to dial it.
5773 ///
5774 /// # Errors
5775 ///
5776 /// [`FfiError::Lan`]`{ kind: "lan_socket" }` when the endpoint has no bound socket, which
5777 /// cannot happen while it is open.
5778 pub fn record(&self) -> Result<Vec<u8>, FfiError> {
5779 let port = self.port()?;
5780 Ok(LanAddress::new(self.inner.endpoint_key(), port)
5781 .encode()
5782 .to_vec())
5783 }
5784
5785 /// Registers a peer this channel may send to.
5786 ///
5787 /// `socket` is `ip:port`, taken from a lobby announcement: see [`FfiLanPeer::socket`] for
5788 /// why nothing else can supply it. **Its port is not used**: the record carries the port the
5789 /// endpoint listens on, while the address a datagram arrives from carries the lobby's, so
5790 /// only its IP is kept.
5791 ///
5792 /// # Errors
5793 ///
5794 /// [`FfiError::InvalidDeviceId`] for a malformed identifier,
5795 /// [`FfiError::Lan`]`{ kind: "bad_socket" }` for an address that is not `ip:port`, and the
5796 /// transport's own kinds for a record it cannot parse.
5797 pub fn remember_peer(
5798 &self,
5799 device_id: String,
5800 record: Vec<u8>,
5801 socket: String,
5802 ) -> Result<(), FfiError> {
5803 let device = parse_device_id(&device_id)?;
5804 let address = LanAddress::decode(&record).map_err(lan_error)?;
5805 let socket = parse_socket(&socket)?;
5806 self.inner.remember_peer(PeerAddress::new(
5807 device,
5808 address.endpoint_key,
5809 Some(SocketAddr::new(socket.ip(), address.port)),
5810 ));
5811
5812 let mut peers = self.lock_peers();
5813 if !peers.contains(&device) {
5814 peers.push(device);
5815 }
5816 Ok(())
5817 }
5818
5819 /// Forgets a peer, and closes the connection to it.
5820 ///
5821 /// # Errors
5822 ///
5823 /// [`FfiError::InvalidDeviceId`] when the identifier is malformed.
5824 pub fn forget_peer(&self, device_id: String) -> Result<(), FfiError> {
5825 let device = parse_device_id(&device_id)?;
5826 self.inner.forget_peer(&device);
5827 self.lock_peers().retain(|known| *known != device);
5828 Ok(())
5829 }
5830
5831 /// This channel as the transport trait the sessions take.
5832 ///
5833 /// The host side passes the result wherever it passes its own channel: one exchange over
5834 /// two kinds of carrier, with no branch in the session and nothing copied. UniFFI hands
5835 /// this object across as an [`FfiTransport`] implementation.
5836 #[must_use]
5837 pub fn transport(self: &Arc<Self>) -> Arc<dyn FfiTransport> {
5838 Arc::clone(self) as Arc<dyn FfiTransport>
5839 }
5840
5841 /// Starts announcing this endpoint on the local network, and collecting announcements.
5842 ///
5843 /// # Errors
5844 ///
5845 /// [`FfiError::Lan`]: `lan_socket` when the endpoint has no bound socket, and the
5846 /// transport's socket kinds when the multicast group cannot be joined — no network, or
5847 /// network policy blocking multicast.
5848 pub fn open_lobby(&self) -> Result<Arc<FfiLanLobby>, FfiError> {
5849 let port = self.port()?;
5850 let lobby = Lobby::open(self.me, self.inner.endpoint_key(), port).map_err(lan_error)?;
5851 Ok(Arc::new(FfiLanLobby {
5852 inner: Mutex::new(lobby),
5853 }))
5854 }
5855
5856 /// Closes the endpoint: every connection is closed and its runtime stops.
5857 ///
5858 /// Calling it more than once is safe.
5859 pub fn shutdown(&self) {
5860 self.inner.shutdown();
5861 }
5862}
5863
5864/// The five methods a session calls, with this channel behind them.
5865///
5866/// A plain Rust implementation of the trait the host side implements for itself, so the
5867/// session takes a `dyn FfiTransport` and cannot tell which of the two it was handed.
5868impl FfiTransport for FfiLanChannel {
5869 /// One message may be up to the transport's own cap, the channel may be reused, and it
5870 /// carries a whole payload.
5871 fn capabilities(&self) -> FfiTransportCapabilities {
5872 FfiTransportCapabilities {
5873 roster: FfiRoster::MultiParty,
5874 max_message_len: Some(MAX_MESSAGE_LEN as u64),
5875 max_qr_version: None,
5876 single_use: false,
5877 can_carry_payload: true,
5878 }
5879 }
5880
5881 fn local_device(&self) -> String {
5882 self.me.to_hex()
5883 }
5884
5885 /// Returns once the send is **accepted**: the write happens on the transport's own thread
5886 /// and cannot report back through this call. What is knowable now — a recipient that was
5887 /// never registered, or a message past the declared cap — is reported here.
5888 fn send(&self, to: FfiRecipient, message: Vec<u8>) -> Result<(), FfiError> {
5889 let to = match to {
5890 FfiRecipient::One { device_id } => LanRecipient::One(parse_device_id(&device_id)?),
5891 FfiRecipient::All => LanRecipient::All,
5892 };
5893 self.inner.send(to, &message).map_err(lan_error)
5894 }
5895
5896 /// `Ok(None)` is "nothing available", not a failure; the caller waits or does something
5897 /// else. The sender is the device the message claims, which the core compares with the
5898 /// sender inside the payload.
5899 fn receive(&self) -> Result<Option<FfiReceivedMessage>, FfiError> {
5900 match self.inner.try_receive() {
5901 Ok((sender, message)) => Ok(Some(FfiReceivedMessage {
5902 sender: sender.to_hex(),
5903 message,
5904 })),
5905 Err(qualia_transport::Error::TransportEmpty) => Ok(None),
5906 Err(other) => Err(lan_error(other)),
5907 }
5908 }
5909
5910 /// The peers this channel will send to, which is what `All` means.
5911 fn peers(&self) -> Result<Vec<String>, FfiError> {
5912 Ok(self.lock_peers().iter().map(|id| id.to_hex()).collect())
5913 }
5914}
5915
5916impl FfiLanChannel {
5917 /// This endpoint's port, or the socket kind when it has none.
5918 fn port(&self) -> Result<u16, FfiError> {
5919 self.inner.port().ok_or_else(|| {
5920 lan_error(qualia_transport::Error::LanSocket {
5921 operation: "endpoint_port",
5922 })
5923 })
5924 }
5925
5926 /// Takes the peer-table lock, ignoring poisoning (see [`FfiVault::lock`]).
5927 fn lock_peers(&self) -> std::sync::MutexGuard<'_, Vec<DeviceId>> {
5928 self.peers
5929 .lock()
5930 .unwrap_or_else(std::sync::PoisonError::into_inner)
5931 }
5932}
5933
5934/// The lobby: which devices are announcing themselves on this network segment.
5935///
5936/// # What it does
5937///
5938/// One UDP socket in the multicast group and one background thread that announces this
5939/// endpoint every thirty seconds and collects what the others announce, keeping a view of the
5940/// peers that have not gone stale after three minutes.
5941///
5942/// # It discovers, it does not connect
5943///
5944/// No payload travels through the lobby. Its answer is "who is probably here, and where to
5945/// reach them", which is a **hint**: an announcement is unauthenticated, so a peer in this
5946/// list may be a device claiming an identity it does not hold. What a connection proves is the
5947/// endpoint key, and what a payload proves is the master key.
5948///
5949/// # It is only useful on one segment
5950///
5951/// Multicast reaches one broadcast domain. Where it does not, a peer has to be handed an
5952/// address out of band instead, and the only party that can supply a dialable one is a
5953/// receiver — see [`FfiLanPeer::socket`].
5954#[derive(uniffi::Object)]
5955pub struct FfiLanLobby {
5956 /// The lobby, including its worker thread and its own view.
5957 inner: Mutex<Lobby>,
5958}
5959
5960#[uniffi::export]
5961impl FfiLanLobby {
5962 /// Every peer the lobby knows, whether or not it is still here.
5963 ///
5964 /// A peer that said goodbye or went stale is reported too, with `here` false: "switched
5965 /// off" and "lost the network" look different to a user, and dropping the entry would lose
5966 /// that difference.
5967 ///
5968 /// **The pending events are drained first.** The view this reads is the caller's copy, and
5969 /// the lobby advances it as events are taken off its queue: reading the view without taking
5970 /// them reports an empty list however many devices are announcing themselves. Draining is
5971 /// what the transport's own documentation asks of a caller, and it makes a second call
5972 /// report what the first one did.
5973 #[must_use]
5974 pub fn peers(&self) -> Vec<FfiLanPeer> {
5975 let mut lobby = self.lock();
5976 while lobby.try_poll().is_some() {}
5977 lobby.view().all().into_iter().map(lan_peer_of).collect()
5978 }
5979
5980 /// Announces this endpoint now instead of waiting for the interval.
5981 ///
5982 /// # Errors
5983 ///
5984 /// [`FfiError::Lan`] with the transport's socket kinds when the datagram cannot be sent.
5985 pub fn announce_now(&self) -> Result<(), FfiError> {
5986 self.lock().announce_now().map_err(lan_error)
5987 }
5988
5989 /// Closes the lobby: one `Bye`, then the worker thread and the socket.
5990 ///
5991 /// Calling it more than once is safe.
5992 pub fn shutdown(&self) {
5993 self.lock().shutdown();
5994 }
5995}
5996
5997impl FfiLanLobby {
5998 /// Takes the lobby lock, ignoring poisoning (see [`FfiVault::lock`]).
5999 fn lock(&self) -> std::sync::MutexGuard<'_, Lobby> {
6000 self.inner
6001 .lock()
6002 .unwrap_or_else(std::sync::PoisonError::into_inner)
6003 }
6004}
6005
6006/// Reduces one peer of the lobby's view to what the host side is handed.
6007fn lan_peer_of(peer: Peer) -> FfiLanPeer {
6008 FfiLanPeer {
6009 device_id: peer.device.to_hex(),
6010 record: LanAddress::new(peer.endpoint_key, peer.port)
6011 .encode()
6012 .to_vec(),
6013 socket: peer.socket().map(|socket| socket.to_string()),
6014 here: peer.status == PeerStatus::Here,
6015 }
6016}
6017
6018/// Parses `ip:port`, the form a socket address crosses this boundary in.
6019///
6020/// That form is the one `std::net::SocketAddr` prints, so an address taken from a lobby
6021/// announcement round-trips through it unchanged.
6022fn parse_socket(text: &str) -> Result<SocketAddr, FfiError> {
6023 text.parse::<SocketAddr>().map_err(|_| FfiError::Lan {
6024 kind: "bad_socket".to_owned(),
6025 detail: "the address is not `ip:port`".to_owned(),
6026 })
6027}
6028
6029/// Reduces a LAN failure to a kind and a message, without any payload.
6030///
6031/// Separate from [`qr_error`] because the two halves of `qualia-transport` fail differently:
6032/// the code channel fails on text and on capacity, this one on sockets, connections and the
6033/// transport's own limits. The wildcard arm is required by the transport's `#[non_exhaustive]`
6034/// error enum, so an unmapped variant reaches the host as `lan_error`; its own test pins the
6035/// kinds that matter.
6036fn lan_error(source: qualia_transport::Error) -> FfiError {
6037 use qualia_transport::Error as LanError;
6038
6039 let kind = match &source {
6040 LanError::NoConnectionToPeer => "no_connection_to_peer",
6041 LanError::TransportEmpty => "transport_empty",
6042 LanError::PayloadTooLarge { .. } => "payload_too_large",
6043 LanError::NoLanPeersFound => "no_lan_peers_found",
6044 LanError::LanSocket { .. } => "lan_socket",
6045 LanError::LanTransport { .. } => "lan_transport",
6046 LanError::NotOurLanAddress => "not_our_lan_address",
6047 LanError::BadLanAddressLength { .. } => "bad_lan_address_length",
6048 LanError::ZeroPort => "zero_port",
6049 _ => "lan_error",
6050 };
6051
6052 FfiError::Lan {
6053 kind: kind.to_owned(),
6054 detail: source.to_string(),
6055 }
6056}
6057
6058#[cfg(test)]
6059mod tests {
6060 use super::*;
6061 use qualia_dik::{MockDirectMasterKeyStore, MockWrappedMasterKeyStore};
6062
6063 #[test]
6064 fn a_qr_code_survives_encoding_and_decoding() {
6065 for text in [
6066 "hello",
6067 "otpauth://totp/Example:alice?secret=MZXW6YTBOI",
6068 "中文内容,含标点。",
6069 ] {
6070 let image = qr_encode(text.to_owned(), FfiEcLevel::Medium, 20, 4, 4)
6071 .expect("the text must fit in a version 20 code");
6072 assert_eq!(image.pixels.len(), (image.width * image.height) as usize);
6073 assert!(image.pixels.contains(&0), "the code must have dark modules");
6074 assert!(
6075 image.pixels.contains(&255),
6076 "the code must have light modules"
6077 );
6078
6079 let decoded =
6080 qr_decode(image.pixels, image.width, image.height).expect("the code must decode");
6081 assert_eq!(decoded, text, "the text must come back unchanged");
6082 }
6083 }
6084
6085 #[test]
6086 fn a_buffer_that_does_not_match_the_dimensions_is_an_error() {
6087 let error = qr_decode(vec![0; 10], 100, 100).expect_err("a short buffer must be rejected");
6088 assert_eq!(error.kind(), "bad_image_geometry");
6089 }
6090
6091 #[test]
6092 fn an_image_without_a_code_reports_that_rather_than_panicking() {
6093 let error = qr_decode(vec![255; 64 * 64], 64, 64).expect_err("a blank image has no code");
6094 assert_eq!(error.kind(), "no_qr_found");
6095 }
6096
6097 #[test]
6098 fn text_that_does_not_fit_is_rejected_rather_than_truncated() {
6099 let error = qr_encode("a".repeat(8_000), FfiEcLevel::Medium, 20, 4, 4)
6100 .expect_err("the text must not fit");
6101 assert_eq!(error.kind(), "payload_too_large");
6102 }
6103
6104 #[test]
6105 fn zero_scale_is_rejected() {
6106 let error = qr_encode("x".to_owned(), FfiEcLevel::Medium, 20, 0, 4)
6107 .expect_err("zero scale cannot render");
6108 assert_eq!(error.kind(), "bad_render_geometry");
6109 }
6110
6111 const RFC_SECRET: &str = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
6112
6113 #[test]
6114 fn rfc6238_vector_survives_the_ffi_boundary() {
6115 let code = totp_code(
6116 RFC_SECRET.to_owned(),
6117 59,
6118 30,
6119 FfiAlgorithm::Sha1,
6120 FfiDigits::Eight,
6121 )
6122 .expect("should compute");
6123 assert_eq!(code, "94287082");
6124 }
6125
6126 #[test]
6127 fn uri_roundtrips_through_ffi_types() {
6128 let original = "otpauth://totp/ACME:alice?secret=JBSWY3DPEHPK3PXP&issuer=ACME&algorithm=SHA256&digits=7&period=45";
6129 let parsed = parse_otpauth_uri(original.to_owned()).expect("should parse");
6130 assert_eq!(parsed.account, "alice");
6131 assert_eq!(parsed.issuer.as_deref(), Some("ACME"));
6132 assert_eq!(parsed.algorithm, FfiAlgorithm::Sha256);
6133 assert_eq!(parsed.digits, FfiDigits::Seven);
6134 assert_eq!(parsed.kind, FfiEntryKind::Totp { period: 45 });
6135
6136 let back = serialize_otpauth_uri(parsed.clone()).expect("should serialize");
6137 let reparsed = parse_otpauth_uri(back).expect("should parse again");
6138 assert_eq!(
6139 reparsed, parsed,
6140 "the entry should be equivalent after an FFI round trip"
6141 );
6142 }
6143
6144 #[test]
6145 fn oversized_input_is_rejected_at_the_boundary() {
6146 let huge = "A".repeat(MAX_SECRET_CHARS + 1);
6147 match secret_byte_len(huge) {
6148 Err(FfiError::InputTooLong {
6149 kind,
6150 limit,
6151 actual,
6152 }) => {
6153 assert_eq!(kind, "secret");
6154 assert_eq!(limit, MAX_SECRET_CHARS as u64);
6155 assert_eq!(actual, (MAX_SECRET_CHARS + 1) as u64);
6156 }
6157 other => panic!("expected InputTooLong, got {other:?}"),
6158 }
6159 }
6160
6161 #[test]
6162 fn length_limit_counts_characters_not_bytes() {
6163 let chinese = "中".repeat(MAX_SECRET_CHARS);
6164 match secret_byte_len(chinese) {
6165 Err(FfiError::InputTooLong { .. }) => {
6166 panic!("counting characters must not hit the limit")
6167 }
6168 Err(FfiError::Core { .. }) => {}
6169 Err(FfiError::Qr { .. }) => {
6170 panic!("the code channel is not involved in a length check")
6171 }
6172 Err(other) => panic!("unexpected error kind {}", other.kind()),
6173 Ok(_) => panic!("non-Base32 input must not parse"),
6174 }
6175 }
6176
6177 #[test]
6178 fn hotp_matches_rfc4226_through_ffi() {
6179 let expected = ["755224", "287082", "359152", "969429"];
6180 for (counter, want) in expected.iter().enumerate() {
6181 let got = hotp_code(
6182 RFC_SECRET.to_owned(),
6183 counter as u64,
6184 FfiAlgorithm::Sha1,
6185 FfiDigits::Six,
6186 )
6187 .expect("should compute");
6188 assert_eq!(&got, want, "counter={counter}");
6189 }
6190 }
6191
6192 #[test]
6193 fn drift_verification_returns_the_offset() {
6194 let code = totp_code(
6195 RFC_SECRET.to_owned(),
6196 59,
6197 30,
6198 FfiAlgorithm::Sha1,
6199 FfiDigits::Eight,
6200 )
6201 .expect("should compute");
6202 let hit = verify_code_with_drift(
6203 RFC_SECRET.to_owned(),
6204 1,
6205 1,
6206 FfiAlgorithm::Sha1,
6207 FfiDigits::Eight,
6208 code.clone(),
6209 )
6210 .expect("should verify");
6211 assert_eq!(hit, Some(0));
6212
6213 let next = totp_code(
6214 RFC_SECRET.to_owned(),
6215 89,
6216 30,
6217 FfiAlgorithm::Sha1,
6218 FfiDigits::Eight,
6219 )
6220 .expect("should compute");
6221 let offset = verify_code_with_drift(
6222 RFC_SECRET.to_owned(),
6223 1,
6224 1,
6225 FfiAlgorithm::Sha1,
6226 FfiDigits::Eight,
6227 next,
6228 )
6229 .expect("should verify");
6230 assert_eq!(offset, Some(1));
6231 }
6232
6233 #[test]
6234 fn generated_secrets_are_32_bytes_and_unique() {
6235 let a = generate_secret_base32().expect("should generate");
6236 let b = generate_secret_base32().expect("should generate");
6237 assert_ne!(a, b, "two generated secrets must differ");
6238 assert_eq!(secret_byte_len(a).expect("should parse"), 32);
6239 }
6240
6241 #[test]
6242 fn colon_in_account_is_rejected_at_serialize() {
6243 let auth = FfiOtpAuth {
6244 kind: FfiEntryKind::Totp { period: 30 },
6245 secret_base32: "JBSWY3DPEHPK3PXP".to_owned(),
6246 issuer: None,
6247 account: "a:b".to_owned(),
6248 algorithm: FfiAlgorithm::Sha1,
6249 digits: FfiDigits::Six,
6250 };
6251 assert!(
6252 serialize_otpauth_uri(auth).is_err(),
6253 "an account name containing a colon must be rejected"
6254 );
6255 }
6256
6257 // ── Registration, login, recovery, challenge-response ────────────
6258
6259 /// A device identity for tests: keys are derived deterministically from the
6260 /// label.
6261 ///
6262 /// Determinism keeps "the same device" consistent between calls, as a real
6263 /// platform key store does (the same label returns the same key). With true
6264 /// randomness, the property "getting the same label again returns the same
6265 /// key" could not be verified.
6266 #[derive(Default)]
6267 struct TestKeys {
6268 keys: std::sync::Mutex<std::collections::HashMap<String, p256::ecdsa::SigningKey>>,
6269 }
6270
6271 impl TestKeys {
6272 /// Returns or creates the signing key for a label.
6273 fn key_for(&self, label: &str) -> p256::ecdsa::SigningKey {
6274 let mut keys = self.keys.lock().expect("no lock poisoning in tests");
6275 keys.entry(label.to_owned())
6276 .or_insert_with(|| {
6277 let scalar = sha2::Digest::finalize({
6278 let mut hasher = <sha2::Sha256 as sha2::Digest>::new();
6279 sha2::Digest::update(&mut hasher, label.as_bytes());
6280 hasher
6281 });
6282 p256::ecdsa::SigningKey::from_slice(&scalar).expect("the scalar must be valid")
6283 })
6284 .clone()
6285 }
6286 }
6287
6288 impl FfiDeviceKeys for TestKeys {
6289 fn get_or_create(&self, label: String) -> Result<Vec<u8>, FfiError> {
6290 let key = self.key_for(&label);
6291 Ok(key.verifying_key().to_sec1_point(false).as_bytes().to_vec())
6292 }
6293
6294 fn sign(&self, label: String, message: Vec<u8>) -> Result<Vec<u8>, FfiError> {
6295 use p256::ecdsa::signature::Signer as _;
6296 let key = self.key_for(&label);
6297 let signature: p256::ecdsa::Signature = key.sign(&message);
6298 Ok(signature.to_bytes().to_vec())
6299 }
6300 }
6301
6302 /// A platform key store for tests: keeps the master key in memory.
6303 #[derive(Default)]
6304 struct TestStore {
6305 master: std::sync::Mutex<Option<Vec<u8>>>,
6306 }
6307
6308 impl FfiSecureStore for TestStore {
6309 fn load_master_key(&self) -> Result<Option<Vec<u8>>, FfiError> {
6310 Ok(self
6311 .master
6312 .lock()
6313 .expect("no lock poisoning in tests")
6314 .clone())
6315 }
6316
6317 fn store_master_key(&self, key: Vec<u8>) -> Result<(), FfiError> {
6318 *self.master.lock().expect("no lock poisoning in tests") = Some(key);
6319 Ok(())
6320 }
6321
6322 fn delete_master_key(&self) -> Result<(), FfiError> {
6323 *self.master.lock().expect("no lock poisoning in tests") = None;
6324 Ok(())
6325 }
6326
6327 fn is_hardware_backed(&self) -> bool {
6328 false
6329 }
6330 }
6331
6332 /// A minimal usable entry.
6333 fn sample_entry(name: &str) -> FfiVaultEntry {
6334 FfiVaultEntry {
6335 id: String::new(),
6336 name: name.to_owned(),
6337 issuer: Some("ACME".to_owned()),
6338 account: "alice".to_owned(),
6339 secret_base32: "JBSWY3DPEHPK3PXP".to_owned(),
6340 algorithm: FfiAlgorithm::Sha1,
6341 digits: FfiDigits::Six,
6342 kind: FfiEntryKind::Totp { period: 30 },
6343 pin_set: false,
6344 note: String::new(),
6345 created_at: 0,
6346 updated_at: 0,
6347 icon: None,
6348 }
6349 }
6350
6351 const NOW: u64 = 1_700_000_000;
6352
6353 /// An **empty** vault must also tell whether the master key is right.
6354 ///
6355 /// This covers the gap that format version 2 closes: in version 1 the master
6356 /// key only took part in the AEAD of each record, and an empty file has no
6357 /// records, so any 32-byte key opened it and the user interface showed
6358 /// "unlocked" for a wrong key.
6359 ///
6360 /// The observable behavior for the host side is that `FfiVault.open` rejects a
6361 /// wrong master key even for a file with **no entries at all**, so "the open
6362 /// succeeded" can be taken as "unlocked" directly.
6363 #[test]
6364 fn an_empty_vault_rejects_a_wrong_master_key() {
6365 let store_a = Arc::new(TestStore::default());
6366 let vault_a = FfiVault::register(
6367 Arc::new(TestKeys::default()),
6368 store_a,
6369 "device-a".to_owned(),
6370 )
6371 .expect("registration should succeed");
6372 assert!(
6373 vault_a.is_empty(),
6374 "precondition: this vault really is empty"
6375 );
6376 let data = vault_a.serialize().expect("should serialize");
6377
6378 let store_b = Arc::new(TestStore::default());
6379 FfiVault::register(
6380 Arc::new(TestKeys::default()),
6381 store_b.clone(),
6382 "device-b".to_owned(),
6383 )
6384 .expect("registration should succeed");
6385
6386 let error = FfiVault::open(store_b, data)
6387 .err()
6388 .expect("an empty vault must be rejected with a wrong key");
6389 assert_eq!(
6390 error.kind(),
6391 "vault_decryption_failed",
6392 "it must be \"cannot decrypt\", not \"not registered\": the two lead to completely different user interface actions"
6393 );
6394 }
6395
6396 /// A registered vault accepts entries, computes codes, and can be serialized
6397 /// and opened again.
6398 ///
6399 /// This follows the most common host-side path: register → add entry →
6400 /// persist → reopen → read a code.
6401 #[test]
6402 fn a_registered_vault_roundtrips_through_the_boundary() {
6403 let keys = Arc::new(TestKeys::default());
6404 let store = Arc::new(TestStore::default());
6405 let vault = FfiVault::register(keys.clone(), store.clone(), "device-a".to_owned())
6406 .expect("registration should succeed");
6407 assert!(vault.is_empty(), "registration adds no entries");
6408
6409 let id = vault
6410 .add_entry(sample_entry("GitHub"), NOW)
6411 .expect("should add the entry");
6412 assert_eq!(vault.entry_count(), 1);
6413
6414 let code = vault
6415 .code_for(id.clone(), NOW)
6416 .expect("should compute the code");
6417 assert_eq!(code.len(), 6, "six-digit code");
6418
6419 let data = vault.serialize().expect("should serialize");
6420 let reopened =
6421 FfiVault::open(store, data).expect("should open with the master key from the store");
6422 assert_eq!(reopened.entry_count(), 1);
6423 assert_eq!(
6424 reopened.code_for(id, NOW).expect("should compute the code"),
6425 code,
6426 "the same entry must produce the same code after reopening"
6427 );
6428 }
6429
6430 /// Updating an entry must not change its identifier and must not leave a
6431 /// tombstone.
6432 ///
6433 /// A changed identifier reads as one deletion plus one insertion during sync,
6434 /// and a tombstone reads as "this entry was deleted"; both cause a visible
6435 /// error on another device.
6436 #[test]
6437 fn updating_an_entry_keeps_its_id_and_leaves_no_tombstone() {
6438 let keys = Arc::new(TestKeys::default());
6439 let store = Arc::new(TestStore::default());
6440 let vault = FfiVault::register(keys, store, "device-a".to_owned())
6441 .expect("registration should succeed");
6442 let id = vault
6443 .add_entry(sample_entry("GitHub"), NOW)
6444 .expect("should add the entry");
6445
6446 let mut edited = sample_entry("GitHub (work)");
6447 edited.id = id.clone();
6448 vault
6449 .update_entry(edited, NOW + 60)
6450 .expect("should update the entry");
6451
6452 assert_eq!(vault.entry_count(), 1, "an update must not add an entry");
6453 assert_eq!(vault.tombstone_count(), 0, "an update is not a deletion");
6454 let entry = vault
6455 .entry(id)
6456 .expect("the identifier must still point at the entry");
6457 assert_eq!(entry.name, "GitHub (work)");
6458 assert_eq!(
6459 entry.created_at, NOW,
6460 "the creation time should be preserved"
6461 );
6462 }
6463
6464 /// A **nonexistent** identifier must be reported separately by update and by
6465 /// removal.
6466 ///
6467 /// Collapsing the two would show an empty list where the user was just
6468 /// looking at an entry.
6469 #[test]
6470 fn a_missing_entry_id_is_reported_as_not_found() {
6471 let keys = Arc::new(TestKeys::default());
6472 let store = Arc::new(TestStore::default());
6473 let vault = FfiVault::register(keys, store, "device-a".to_owned())
6474 .expect("registration should succeed");
6475
6476 let mut ghost = sample_entry("ghost");
6477 ghost.id = "0".repeat(32);
6478 let error = vault
6479 .update_entry(ghost, NOW)
6480 .expect_err("a nonexistent identifier should be reported");
6481 assert_eq!(error.kind(), "vault_entry_not_found");
6482
6483 assert!(
6484 !vault
6485 .remove_entry("0".repeat(32), NOW)
6486 .expect("should be callable"),
6487 "removing a nonexistent entry should return false"
6488 );
6489 assert_eq!(vault.entry_count(), 0);
6490 }
6491
6492 /// Logging in hands the master key to the new device unchanged, so the new
6493 /// device can open the old device's vault.
6494 ///
6495 /// Asserting only "no error" is not enough: an implementation that replaced
6496 /// the master key with a fresh random value reports no error either.
6497 #[test]
6498 fn login_transfers_the_master_key_across_the_boundary() {
6499 let old_keys = Arc::new(TestKeys::default());
6500 let old_store = Arc::new(TestStore::default());
6501 let old_vault =
6502 FfiVault::register(old_keys.clone(), old_store.clone(), "device-a".to_owned())
6503 .expect("registration should succeed");
6504 old_vault
6505 .add_entry(sample_entry("GitHub"), NOW)
6506 .expect("should add the entry");
6507 let data = old_vault.serialize().expect("should serialize");
6508
6509 let login = FfiLogin::begin().expect("should start the login");
6510 let joiner_keys = Arc::new(TestKeys::default());
6511 let joiner = FfiJoiner::from_qr(login.payload(), "device-b".to_owned())
6512 .expect("should establish the session");
6513 let request = joiner
6514 .request(joiner_keys.clone())
6515 .expect("should send the request");
6516 let reply = login
6517 .authorize(old_vault.clone(), request)
6518 .expect("should authorize");
6519 let joiner_store = Arc::new(TestStore::default());
6520 let master = joiner
6521 .complete(reply, joiner_keys, joiner_store.clone())
6522 .expect("should complete the login");
6523
6524 assert_eq!(master.len(), 32, "the master key is 32 bytes");
6525 assert!(
6526 joiner_store.master.lock().expect("not poisoned").is_some(),
6527 "the master key must already be in the new device's platform store"
6528 );
6529
6530 let new_vault = FfiVault::open(joiner_store, data)
6531 .expect("the new device should open the old device's vault");
6532 assert_eq!(
6533 new_vault.entry_count(),
6534 1,
6535 "the old device's entry should be visible"
6536 );
6537 }
6538
6539 /// A login can be completed only once, and the second attempt reports the
6540 /// same error as a forged reply.
6541 #[test]
6542 fn a_login_cannot_be_completed_twice_across_the_boundary() {
6543 let old_keys = Arc::new(TestKeys::default());
6544 let old_store = Arc::new(TestStore::default());
6545 let old_vault = FfiVault::register(old_keys, old_store, "device-a".to_owned())
6546 .expect("registration should succeed");
6547 let login = FfiLogin::begin().expect("should start the login");
6548 let joiner_keys = Arc::new(TestKeys::default());
6549 let joiner = FfiJoiner::from_qr(login.payload(), "device-b".to_owned())
6550 .expect("should establish the session");
6551 let request = joiner
6552 .request(joiner_keys.clone())
6553 .expect("should send the request");
6554 let reply = login
6555 .authorize(old_vault, request)
6556 .expect("should authorize");
6557 let joiner_store = Arc::new(TestStore::default());
6558
6559 joiner
6560 .complete(reply.clone(), joiner_keys.clone(), joiner_store.clone())
6561 .expect("the first attempt should succeed");
6562 let error = joiner
6563 .complete(reply, joiner_keys, joiner_store)
6564 .expect_err("the second attempt must fail");
6565 assert_eq!(
6566 error.kind(),
6567 "pairing_authentication_failed",
6568 "it must be the same error as a forged reply, so a replayer gets no distinguishing signal"
6569 );
6570 }
6571
6572 /// A recovery key file restores the same master key.
6573 #[test]
6574 fn a_recovery_file_restores_the_same_master_key() {
6575 let keys = Arc::new(TestKeys::default());
6576 let store = Arc::new(TestStore::default());
6577 let vault = FfiVault::register(keys, store, "device-a".to_owned())
6578 .expect("registration should succeed");
6579 vault
6580 .add_entry(sample_entry("GitHub"), NOW)
6581 .expect("should add the entry");
6582 let data = vault.serialize().expect("should serialize");
6583
6584 let file = vault
6585 .export_recovery_key("correct horse".to_owned(), Some(8), Some(1), Some(1))
6586 .expect("should export");
6587
6588 let new_store = Arc::new(TestStore::default());
6589 let restored =
6590 import_recovery_key(file.clone(), "correct horse".to_owned(), new_store.clone())
6591 .expect("should import");
6592 assert_eq!(restored.len(), 32);
6593
6594 let reopened = FfiVault::open(new_store, data).expect("should open");
6595 assert_eq!(reopened.entry_count(), 1);
6596
6597 let wrong = import_recovery_key(
6598 file,
6599 "wrong passphrase".to_owned(),
6600 Arc::new(TestStore::default()),
6601 )
6602 .expect_err("a wrong passphrase should fail");
6603 assert_eq!(wrong.kind(), "recovery_authentication_failed");
6604 }
6605
6606 /// All three steps of the challenge-response must work, with the master key
6607 /// staying inside the boundary.
6608 #[test]
6609 fn challenge_response_confirms_both_sides_across_the_boundary() {
6610 let keys = Arc::new(TestKeys::default());
6611 let store = Arc::new(TestStore::default());
6612 let vault = FfiVault::register(keys.clone(), store, "device-a".to_owned())
6613 .expect("registration should succeed");
6614
6615 let verifier = FfiVerifier::begin().expect("should start the challenge");
6616 let responder =
6617 FfiResponder::accept(verifier.challenge()).expect("should accept the challenge");
6618 let response = responder
6619 .respond(vault.clone(), "device-a".to_owned(), keys)
6620 .expect("should produce the response");
6621
6622 assert!(
6623 verifier
6624 .verify(response, vault.clone())
6625 .expect("should verify the response"),
6626 "the second step should pass"
6627 );
6628
6629 let session_id = verifier
6630 .session_id()
6631 .expect("a session identifier should exist after verification");
6632 let confirmation = verifier
6633 .confirmation()
6634 .expect("a confirmation tag should exist after verification");
6635 assert!(
6636 responder
6637 .confirm(vault, session_id, confirmation)
6638 .expect("should confirm"),
6639 "the third step should pass"
6640 );
6641 }
6642
6643 /// Challenge-response is single use, like pairing.
6644 #[test]
6645 fn a_challenge_cannot_be_verified_twice() {
6646 let keys = Arc::new(TestKeys::default());
6647 let store = Arc::new(TestStore::default());
6648 let vault = FfiVault::register(keys.clone(), store, "device-a".to_owned())
6649 .expect("registration should succeed");
6650 let verifier = FfiVerifier::begin().expect("should start the challenge");
6651 let responder =
6652 FfiResponder::accept(verifier.challenge()).expect("should accept the challenge");
6653 let response = responder
6654 .respond(vault.clone(), "device-a".to_owned(), keys)
6655 .expect("should produce the response");
6656
6657 verifier
6658 .verify(response.clone(), vault.clone())
6659 .expect("the first attempt should pass");
6660 let error = verifier
6661 .verify(response, vault)
6662 .expect_err("the second attempt must fail");
6663 assert_eq!(error.kind(), "auth_proof_failed");
6664 }
6665
6666 /// Every kind is unique, and none of them falls into `"unknown"`.
6667 ///
6668 /// `core_error_kind` ends with a `_ => "unknown"` fallback (the core `Error` is
6669 /// marked `non_exhaustive`, so that arm is required). The compiler therefore
6670 /// **does not warn about a newly added core variant**; this test is the
6671 /// reminder: a variant missing from the mapping shows up here as `"unknown"`.
6672 #[test]
6673 fn every_mapped_error_kind_is_distinct_and_known() {
6674 let kinds = [
6675 core_error_kind(&Error::SecretEmpty),
6676 core_error_kind(&Error::VaultTruncated),
6677 core_error_kind(&Error::VaultCorrupt),
6678 core_error_kind(&Error::VaultDecryptionFailed),
6679 core_error_kind(&Error::VaultEntryNotFound),
6680 core_error_kind(&Error::VaultBadKeyCheck),
6681 core_error_kind(&Error::VaultDuplicateGroupId),
6682 core_error_kind(&Error::VaultGroupNotFound),
6683 core_error_kind(&Error::VaultGroupUnknownEntry),
6684 core_error_kind(&Error::VaultTopGroupImmutable),
6685 core_error_kind(&Error::VaultGroupTooManyChildren {
6686 limit: 1,
6687 actual: 2,
6688 }),
6689 core_error_kind(&Error::VaultGroupDuplicateChild),
6690 core_error_kind(&Error::VaultGroupCycle),
6691 core_error_kind(&Error::VaultGroupDepthExceeded {
6692 limit: 3,
6693 actual: 4,
6694 }),
6695 core_error_kind(&Error::MasterKeyWrongLength {
6696 expected: 32,
6697 actual: 1,
6698 }),
6699 core_error_kind(&Error::DeviceKeyMissing),
6700 core_error_kind(&Error::DeviceNotRegistered),
6701 core_error_kind(&Error::DeviceIdentityMismatch),
6702 core_error_kind(&Error::PairingAuthenticationFailed),
6703 core_error_kind(&Error::RecoveryAuthenticationFailed),
6704 // The unlock password wrap. A malformed file, a rejected password, an empty
6705 // password and a damaged cost field are four different answers for the unlock
6706 // screen, so each needs its own kind.
6707 core_error_kind(&Error::LockFileMalformed { reason: "test" }),
6708 core_error_kind(&Error::LockParametersInvalid { reason: "test" }),
6709 core_error_kind(&Error::LockPassphraseEmpty),
6710 core_error_kind(&Error::LockPasswordRejected),
6711 core_error_kind(&Error::LockEncryptionFailed),
6712 core_error_kind(&Error::LockKdfFailed),
6713 core_error_kind(&Error::AuthProofFailed),
6714 core_error_kind(&Error::TimestampZero),
6715 core_error_kind(&Error::SyncAlreadyApplied),
6716 core_error_kind(&Error::SyncNoSessionFound),
6717 core_error_kind(&Error::SyncSenderMismatch),
6718 core_error_kind(&Error::SyncAuthenticationFailed),
6719 core_error_kind(&Error::SyncIconRequiresByteTransport),
6720 core_error_kind(&Error::SyncIdLogFull { limit: 4096 }),
6721 core_error_kind(&Error::TransportFailed { reason: "test" }),
6722 core_error_kind(&Error::TransportAlreadyUsed),
6723 core_error_kind(&Error::TransportMessageTooLarge {
6724 needed: 100,
6725 limit: 10,
6726 }),
6727 core_error_kind(&Error::TransportUnsupported { operation: "test" }),
6728 // Another authenticator's files, in both directions. A file-level failure is what the
6729 // interface shows instead of a preview, and the ways it can fail are the user's
6730 // answers: choose the file again, choose another file, supply the password, correct
6731 // the password, or re-export it from the other application.
6732 core_error_kind(&Error::ImportFileTooLarge {
6733 limit: 1,
6734 actual: 2,
6735 }),
6736 core_error_kind(&Error::ImportTooManyEntries {
6737 limit: 1,
6738 actual: 2,
6739 }),
6740 core_error_kind(&Error::ImportTooDeeplyNested { limit: 1 }),
6741 core_error_kind(&Error::ImportMalformed { reason: "test" }),
6742 core_error_kind(&Error::ImportUnsupportedVersion {
6743 version: 2,
6744 supported: 1,
6745 }),
6746 core_error_kind(&Error::ImportPasswordRequired),
6747 core_error_kind(&Error::ImportEncryptionUnsupported { reason: "test" }),
6748 core_error_kind(&Error::ImportWrongPassword),
6749 core_error_kind(&Error::ImportCorrupted),
6750 core_error_kind(&Error::ImportInvalidKdfParameters { reason: "test" }),
6751 core_error_kind(&Error::ExportPasswordRequired),
6752 core_error_kind(&Error::ExportPasswordEmpty),
6753 core_error_kind(&Error::ExportQrTooLong { position: 1 }),
6754 core_error_kind(&Error::ExportTooLarge),
6755 core_error_kind(&Error::ExportParameterRefused { parameter: "test" }),
6756 // Portable vault containers. Each way one can fail sends the host side somewhere
6757 // different: choose another file, update the application, re-export, or supply the
6758 // passphrase.
6759 core_error_kind(&Error::PortableBadMagic),
6760 core_error_kind(&Error::PortableUnsupportedVersion { version: 2 }),
6761 core_error_kind(&Error::PortableTruncated),
6762 core_error_kind(&Error::PortableLengthMismatch),
6763 core_error_kind(&Error::PortableTooLarge {
6764 limit: 1,
6765 actual: 2,
6766 }),
6767 core_error_kind(&Error::PortableVaultFileWithoutKey),
6768 core_error_kind(&Error::PortablePasswordRequired),
6769 // The master key storage failures of the device identity library. Each one sends
6770 // the host side somewhere different, so each one needs a kind of its own: a blob
6771 // that will not unwrap wants a recovery path, a platform with no backend wants
6772 // the other form, a store that will not hand the material back wants the wrapped
6773 // form, and a machine with no usable provider has to be told to the user.
6774 core_error_kind(&Error::Device(qualia_dik::Error::InvalidWrappedKey {
6775 reason: "test",
6776 code: 0,
6777 })),
6778 core_error_kind(&Error::Device(qualia_dik::Error::InvalidMasterKey {
6779 reason: "test",
6780 })),
6781 core_error_kind(&Error::Device(qualia_dik::Error::KeyNotExportable {
6782 reason: "test",
6783 })),
6784 core_error_kind(&Error::Device(qualia_dik::Error::HardwareUnavailable {
6785 reason: String::from("test"),
6786 })),
6787 core_error_kind(&Error::Device(qualia_dik::Error::UnsupportedPlatform {
6788 platform: "test",
6789 })),
6790 ];
6791 for kind in kinds {
6792 assert_ne!(kind, "unknown", "a variant is not mapped to a stable kind");
6793 }
6794 let unique: std::collections::HashSet<_> = kinds.iter().collect();
6795 assert_eq!(
6796 unique.len(),
6797 kinds.len(),
6798 "two variants map to the same kind, so the client side cannot tell them apart"
6799 );
6800 }
6801
6802 /// The boundary's own errors are branched on by the host side too, so each needs a kind of
6803 /// its own and none may degrade to `unknown`.
6804 #[test]
6805 fn every_boundary_error_kind_is_distinct_and_known() {
6806 let kinds: Vec<String> = vec![
6807 FfiError::InputTooLong {
6808 kind: "secret".to_owned(),
6809 limit: 1,
6810 actual: 2,
6811 }
6812 .kind()
6813 .to_owned(),
6814 FfiError::InputEmpty {
6815 kind: "pin".to_owned(),
6816 }
6817 .kind()
6818 .to_owned(),
6819 FfiError::WrongCodeKind {
6820 kind: "hotp".to_owned(),
6821 wanted: "time".to_owned(),
6822 }
6823 .kind()
6824 .to_owned(),
6825 FfiError::InvalidDeviceId {
6826 kind: "device_id".to_owned(),
6827 }
6828 .kind()
6829 .to_owned(),
6830 FfiError::UnknownConflict {
6831 entry_id: "0".repeat(32),
6832 }
6833 .kind()
6834 .to_owned(),
6835 ];
6836
6837 for kind in &kinds {
6838 assert_ne!(kind, "unknown", "a variant is not mapped to a stable kind");
6839 }
6840 let unique: std::collections::HashSet<_> = kinds.iter().collect();
6841 assert_eq!(
6842 unique.len(),
6843 kinds.len(),
6844 "two variants map to the same kind, so the client side cannot tell them apart"
6845 );
6846 }
6847
6848 /// The kind decides how a code is produced; the host does not choose the function.
6849 #[test]
6850 fn each_kind_produces_its_own_shape_of_code() {
6851 let keys = Arc::new(TestKeys::default());
6852 let store = Arc::new(TestStore::default());
6853 let vault = FfiVault::register(keys, store, "device-a".to_owned())
6854 .expect("registration should succeed");
6855
6856 let motp = FfiVaultEntry {
6857 kind: FfiEntryKind::Motp {
6858 period: 10,
6859 pin: Some("1234".to_owned()),
6860 },
6861 ..sample_entry("mOTP")
6862 };
6863 let motp_id = vault.add_entry(motp, NOW).expect("should add the entry");
6864 let code = vault
6865 .code_for(motp_id.clone(), NOW)
6866 .expect("should compute the mOTP code");
6867 assert_eq!(code.len(), 6, "mOTP codes are six characters");
6868 assert!(
6869 code.chars().all(|character| character.is_ascii_hexdigit()),
6870 "mOTP codes are hexadecimal, actual {code}"
6871 );
6872
6873 let steam = FfiVaultEntry {
6874 kind: FfiEntryKind::Steam,
6875 digits: FfiDigits::Five,
6876 ..sample_entry("Steam")
6877 };
6878 let steam_id = vault.add_entry(steam, NOW).expect("should add the entry");
6879 let code = vault
6880 .code_for(steam_id, NOW)
6881 .expect("should compute the Steam code");
6882 assert_eq!(code.len(), 5, "Steam Guard codes are five characters");
6883
6884 // Yandex OTP verifies its secret length, so this one needs a sixteen-byte secret.
6885 let yandex = FfiVaultEntry {
6886 kind: FfiEntryKind::Yandex {
6887 period: 30,
6888 pin: Some("1234".to_owned()),
6889 },
6890 digits: FfiDigits::Eight,
6891 secret_base32: "LA2V6KMCGYMWWVEW64RNP3JA3I".to_owned(),
6892 ..sample_entry("Yandex")
6893 };
6894 let yandex_id = vault.add_entry(yandex, NOW).expect("should add the entry");
6895 let code = vault
6896 .code_for(yandex_id, NOW)
6897 .expect("should compute the Yandex code");
6898 assert_eq!(code.len(), 8, "Yandex OTP codes are eight letters");
6899 assert!(
6900 code.chars().all(|character| character.is_ascii_lowercase()),
6901 "Yandex OTP codes are lower-case letters, actual {code}"
6902 );
6903
6904 // The PIN is stored but never handed back.
6905 let stored = vault.entry(motp_id).expect("the entry exists");
6906 assert!(stored.pin_set, "the PIN is reported as set");
6907 assert_eq!(
6908 stored.kind,
6909 FfiEntryKind::Motp {
6910 period: 10,
6911 pin: None
6912 },
6913 "and its value does not cross the boundary"
6914 );
6915 }
6916
6917 /// Calling the wrong function for an entry's kind is an error, not a wrong code.
6918 #[test]
6919 fn a_code_the_kind_cannot_produce_is_refused() {
6920 let keys = Arc::new(TestKeys::default());
6921 let store = Arc::new(TestStore::default());
6922 let vault = FfiVault::register(keys, store, "device-a".to_owned())
6923 .expect("registration should succeed");
6924
6925 let totp_id = vault
6926 .add_entry(sample_entry("GitHub"), NOW)
6927 .expect("should add the entry");
6928 let error = vault
6929 .hotp_for(totp_id, 1)
6930 .expect_err("a time-based entry has no counter to advance");
6931 assert_eq!(error.kind(), "wrong_code_kind");
6932
6933 let hotp = FfiVaultEntry {
6934 kind: FfiEntryKind::Hotp { counter: 0 },
6935 ..sample_entry("Bank")
6936 };
6937 let hotp_id = vault.add_entry(hotp, NOW).expect("should add the entry");
6938 let error = vault
6939 .code_for(hotp_id, NOW)
6940 .expect_err("an event-based entry has no clock to read");
6941 assert_eq!(error.kind(), "wrong_code_kind");
6942 }
6943
6944 /// An export is previewed, compared and applied; no secret crosses the boundary while it is.
6945 #[test]
6946 fn an_export_previews_compares_and_applies() {
6947 let keys = Arc::new(TestKeys::default());
6948 let store = Arc::new(TestStore::default());
6949 let vault = FfiVault::register(keys, store, "device-a".to_owned())
6950 .expect("registration should succeed");
6951
6952 // The vault already holds "GitHub" with this secret; the export spells the same account
6953 // differently and adds one the vault does not have.
6954 let existing_id = vault
6955 .add_entry(sample_entry("GitHub"), NOW)
6956 .expect("should add the entry");
6957 let export = "otpauth://totp/ACME:alice?secret=JBSWY3DPEHPK3PXP&issuer=ACME\n\
6958 otpauth://totp/Bank:bob?secret=KRSXG5CTMVRXEZLU&issuer=Bank\n";
6959
6960 assert_eq!(
6961 detect_import_format(export.as_bytes().to_vec()),
6962 Some(FfiImportFormat::Text)
6963 );
6964 let import =
6965 FfiImport::preview(export.as_bytes().to_vec(), FfiImportFormat::Text, None, NOW)
6966 .expect("the export should be read");
6967
6968 let entries = import.entries();
6969 assert_eq!(entries.len(), 2);
6970 assert_eq!(entries[0].position, 1);
6971 assert_eq!(entries[0].account, "alice");
6972 assert!(import.problems().is_empty());
6973
6974 let matches = import.compare(vault.clone());
6975 assert_eq!(matches.len(), 2);
6976 assert_eq!(
6977 matches[0].existing,
6978 vec![existing_id.clone()],
6979 "the same secret identifies the account"
6980 );
6981 assert!(!matches[0].identical, "the name differs");
6982 assert!(matches[0].differences.contains(&"name".to_owned()));
6983 assert!(matches[1].existing.is_empty(), "the second entry is new");
6984
6985 // The user keeps what the vault holds and adds only the second entry.
6986 let applied = import
6987 .apply(
6988 vault.clone(),
6989 vec![FfiImportChoice {
6990 position: 1,
6991 decision: FfiImportDecision::Skip,
6992 }],
6993 )
6994 .expect("the import should apply");
6995 assert_eq!(applied.added, 1);
6996 assert_eq!(applied.skipped, 1);
6997 assert_eq!(vault.entry_count(), 2);
6998 assert_eq!(
6999 vault.entry(existing_id).map(|entry| entry.name),
7000 Some("GitHub".to_owned()),
7001 "a skipped entry keeps what the vault held"
7002 );
7003
7004 // Applying the same import again: the entry that was added the first time is skipped as
7005 // an exact duplicate, while the one the user declined comes back as a question. That is
7006 // the deliberate rule - a differing entry is never resolved automatically, because only
7007 // a human can say whether "ACME:alice" and "GitHub" are the same account.
7008 let again = import
7009 .apply(vault.clone(), Vec::new())
7010 .expect("applies again");
7011 assert_eq!(again.added, 1);
7012 assert_eq!(again.skipped, 1);
7013 assert_eq!(vault.entry_count(), 3);
7014
7015 // Answered with Skip, it leaves the vault alone.
7016 let settled = import
7017 .apply(
7018 vault.clone(),
7019 vec![FfiImportChoice {
7020 position: 1,
7021 decision: FfiImportDecision::Skip,
7022 }],
7023 )
7024 .expect("applies once more");
7025 assert_eq!(settled.added, 0);
7026 assert_eq!(settled.skipped, 2);
7027 assert_eq!(vault.entry_count(), 3);
7028
7029 // An encrypted export without a password is refused rather than read as empty.
7030 let encrypted = br#"{"version":1,"header":{"slots":[],"params":{}},"db":"AAAA"}"#;
7031 assert!(
7032 FfiImport::preview(encrypted.to_vec(), FfiImportFormat::Json, None, NOW).is_err(),
7033 "an encrypted export needs a password"
7034 );
7035 }
7036
7037 /// The kind of an entry without its PIN, which is what a read-back can be compared on.
7038 ///
7039 /// A URI carries a PIN and a JSON entry carries one, so an import that went through those
7040 /// would disagree with the preview, which never hands a PIN back (see [`FfiEntryKind`]).
7041 fn kind_shape(kind: &FfiEntryKind) -> FfiEntryKind {
7042 match kind {
7043 FfiEntryKind::Motp { period, .. } => FfiEntryKind::Motp {
7044 period: *period,
7045 pin: None,
7046 },
7047 FfiEntryKind::Yandex { period, .. } => FfiEntryKind::Yandex {
7048 period: *period,
7049 pin: None,
7050 },
7051 other => other.clone(),
7052 }
7053 }
7054
7055 /// The interface's questions about a format, answered without naming a file.
7056 #[test]
7057 fn the_export_formats_describe_themselves() {
7058 for format in [
7059 FfiExportFormat::Vault,
7060 FfiExportFormat::Text,
7061 FfiExportFormat::Json,
7062 FfiExportFormat::Html,
7063 ] {
7064 assert!(
7065 !export_format_needs_password(format),
7066 "{format:?} is written as it stands"
7067 );
7068 }
7069 for format in [
7070 FfiExportFormat::EncryptedVault,
7071 FfiExportFormat::EncryptedJson,
7072 ] {
7073 assert!(
7074 export_format_needs_password(format),
7075 "{format:?} asks for a password"
7076 );
7077 }
7078
7079 assert_eq!(export_format_extension(FfiExportFormat::Vault), "q2fa");
7080 assert_eq!(
7081 export_format_extension(FfiExportFormat::EncryptedVault),
7082 "q2fa",
7083 "both vault containers are one file with and without a password"
7084 );
7085 assert_eq!(export_format_extension(FfiExportFormat::Text), "txt");
7086 assert_eq!(export_format_extension(FfiExportFormat::Json), "json");
7087 assert_eq!(
7088 export_format_extension(FfiExportFormat::EncryptedJson),
7089 "json",
7090 "both JSON forms are one file with and without a password"
7091 );
7092 assert_eq!(export_format_extension(FfiExportFormat::Html), "html");
7093
7094 assert_eq!(
7095 export_format_group_carrying(FfiExportFormat::Vault),
7096 FfiGroupCarrying::FullTree
7097 );
7098 assert_eq!(
7099 export_format_group_carrying(FfiExportFormat::EncryptedVault),
7100 FfiGroupCarrying::FullTree
7101 );
7102 assert_eq!(
7103 export_format_group_carrying(FfiExportFormat::Json),
7104 FfiGroupCarrying::FlatGroups,
7105 "the JSON forms keep the group names and memberships, not the nesting"
7106 );
7107 assert_eq!(
7108 export_format_group_carrying(FfiExportFormat::EncryptedJson),
7109 FfiGroupCarrying::FlatGroups
7110 );
7111 assert_eq!(
7112 export_format_group_carrying(FfiExportFormat::Text),
7113 FfiGroupCarrying::None,
7114 "a list of URIs has nowhere to put a group"
7115 );
7116 assert_eq!(
7117 export_format_group_carrying(FfiExportFormat::Html),
7118 FfiGroupCarrying::None
7119 );
7120 assert!(!export_format_carries_groups(FfiExportFormat::Text));
7121 assert!(export_format_carries_groups(FfiExportFormat::Json));
7122 assert!(export_format_carries_groups(FfiExportFormat::Vault));
7123 assert_eq!(max_group_depth(), 3);
7124 }
7125
7126 /// A plain `.q2fa` restores on a clean device, and the key is written to its store.
7127 #[test]
7128 fn a_plain_portable_vault_restores_through_the_boundary() {
7129 let (vault, _) = registered_vault("device-a");
7130 vault
7131 .add_entry(sample_entry("GitHub"), NOW)
7132 .expect("the entry should be added");
7133 let bytes = vault
7134 .export(FfiExportFormat::Vault, None)
7135 .expect("the plain container is written");
7136 assert_eq!(
7137 detect_portable_file(bytes.clone()),
7138 Some(FfiPortableKind::PlainVault)
7139 );
7140
7141 let store = Arc::new(TestStore::default());
7142 let restored = open_portable_file(bytes, None, store.clone()).expect("it opens");
7143 assert!(!restored.key_only);
7144 let restored = restored.vault.expect("the file carried a vault");
7145 assert_eq!(restored.entry_count(), 1, "the entry came along");
7146 assert!(
7147 store
7148 .master
7149 .lock()
7150 .expect("no lock poisoning in tests")
7151 .is_some(),
7152 "the restored key must be in the platform store"
7153 );
7154 }
7155
7156 /// The encrypted container needs its passphrase, and a wrong one changes nothing.
7157 #[test]
7158 fn an_encrypted_portable_vault_needs_its_passphrase() {
7159 let (vault, _) = registered_vault("device-a");
7160 vault
7161 .add_entry(sample_entry("GitHub"), NOW)
7162 .expect("the entry should be added");
7163 let bytes = vault
7164 .export(
7165 FfiExportFormat::EncryptedVault,
7166 Some("correct horse".to_owned()),
7167 )
7168 .expect("the encrypted container is written");
7169 assert_eq!(
7170 detect_portable_file(bytes.clone()),
7171 Some(FfiPortableKind::EncryptedVault)
7172 );
7173
7174 let store = Arc::new(TestStore::default());
7175 let missing = open_portable_file(bytes.clone(), None, store.clone());
7176 assert!(
7177 missing.is_err(),
7178 "a passphrase-protected file must not open without one"
7179 );
7180 assert!(
7181 store
7182 .master
7183 .lock()
7184 .expect("no lock poisoning in tests")
7185 .is_none(),
7186 "a failed open must not write a key"
7187 );
7188
7189 let wrong = open_portable_file(bytes.clone(), Some("wrong".to_owned()), store.clone());
7190 assert!(wrong.is_err());
7191
7192 let opened = open_portable_file(bytes, Some("correct horse".to_owned()), store)
7193 .expect("the right passphrase opens it");
7194 assert_eq!(
7195 opened
7196 .vault
7197 .expect("the file carried a vault")
7198 .entry_count(),
7199 1
7200 );
7201 }
7202
7203 /// The two shapes that are not a full backup are recognized and answered.
7204 #[test]
7205 fn a_bare_vault_file_and_a_key_only_file_are_told_apart() {
7206 let (vault, _) = registered_vault("device-a");
7207 let bare = vault.serialize().expect("should serialize");
7208 assert_eq!(
7209 detect_portable_file(bare.clone()),
7210 None,
7211 "a bare vault file is not a portable container"
7212 );
7213 let refused = match open_portable_file(bare, None, Arc::new(TestStore::default())) {
7214 Ok(_) => panic!("a bare vault file carries no key"),
7215 Err(error) => error,
7216 };
7217 assert_eq!(refused.kind(), "portable_vault_file_without_key");
7218
7219 let key_only = vault
7220 .export_recovery_key("passphrase".to_owned(), None, None, None)
7221 .expect("the old key-only file is written");
7222 assert_eq!(
7223 detect_portable_file(key_only.clone()),
7224 Some(FfiPortableKind::KeyOnly)
7225 );
7226 let store = Arc::new(TestStore::default());
7227 let outcome = open_portable_file(key_only, Some("passphrase".to_owned()), store.clone())
7228 .expect("the file is valid");
7229 assert!(outcome.key_only, "the old shape carries no entries");
7230 assert!(outcome.vault.is_none());
7231 assert!(
7232 store
7233 .master
7234 .lock()
7235 .expect("no lock poisoning in tests")
7236 .is_none(),
7237 "nothing was written, so a local vault cannot be left on a new key"
7238 );
7239 }
7240
7241 /// Every kind the vault holds comes back out of every format, across the boundary.
7242 ///
7243 /// The export is the object the host side holds and the bytes are what it would write to a
7244 /// file; the import is what it would do with that file. Both halves are exercised here, in
7245 /// all four formats, because each one carries the fields differently - a URI has no name, a
7246 /// table writes the mOTP secret in hexadecimal, and the encrypted form is a different shape
7247 /// altogether.
7248 #[test]
7249 fn an_export_round_trips_across_the_boundary() {
7250 let (vault, _) = registered_vault("device-a");
7251 let written = [
7252 sample_entry("GitHub"),
7253 FfiVaultEntry {
7254 kind: FfiEntryKind::Hotp { counter: 7 },
7255 ..sample_entry("Bank")
7256 },
7257 FfiVaultEntry {
7258 kind: FfiEntryKind::Steam,
7259 digits: FfiDigits::Five,
7260 ..sample_entry("Steam")
7261 },
7262 FfiVaultEntry {
7263 kind: FfiEntryKind::Motp {
7264 period: 10,
7265 pin: Some("1234".to_owned()),
7266 },
7267 ..sample_entry("mOTP")
7268 },
7269 FfiVaultEntry {
7270 kind: FfiEntryKind::Yandex {
7271 period: 30,
7272 pin: Some("1234".to_owned()),
7273 },
7274 secret_base32: "LA2V6KMCGYMWWVEW64RNP3JA3I".to_owned(),
7275 ..sample_entry("Yandex")
7276 },
7277 ];
7278 for entry in &written {
7279 vault
7280 .add_entry(entry.clone(), NOW)
7281 .expect("the entry should be added");
7282 }
7283
7284 for (format, import_format) in [
7285 (FfiExportFormat::Text, FfiImportFormat::Text),
7286 (FfiExportFormat::Json, FfiImportFormat::Json),
7287 (FfiExportFormat::Html, FfiImportFormat::Html),
7288 (FfiExportFormat::EncryptedJson, FfiImportFormat::Json),
7289 ] {
7290 let password = export_format_needs_password(format).then(|| "correct horse".to_owned());
7291 let bytes = vault
7292 .export(format, password.clone())
7293 .unwrap_or_else(|error| panic!("{format:?} should be written: {error}"));
7294 let import = FfiImport::preview(bytes, import_format, password, NOW)
7295 .unwrap_or_else(|error| panic!("{format:?} should read back: {error}"));
7296
7297 assert!(
7298 import.problems().is_empty(),
7299 "{format:?} produced problems: {:?}",
7300 import.problems()
7301 );
7302 let read = import.entries();
7303 assert_eq!(read.len(), written.len(), "{format:?}");
7304 for entry in &written {
7305 let expected = kind_shape(&entry.kind);
7306 assert!(
7307 read.iter().any(|one| kind_shape(&one.kind) == expected),
7308 "{format:?} came back without {expected:?}: {:?}",
7309 read.iter().map(|one| &one.kind).collect::<Vec<_>>()
7310 );
7311 }
7312 }
7313 }
7314
7315 /// The encrypted form is the only one that hides anything, and it insists on a password.
7316 #[test]
7317 fn an_encrypted_export_needs_a_real_password() {
7318 let (vault, _) = registered_vault("device-a");
7319 vault
7320 .add_entry(sample_entry("GitHub"), NOW)
7321 .expect("the entry should be added");
7322
7323 let missing = vault
7324 .export(FfiExportFormat::EncryptedJson, None)
7325 .expect_err("an encrypted export needs a password");
7326 assert_eq!(missing.kind(), "export_password_required");
7327
7328 let empty = vault
7329 .export(FfiExportFormat::EncryptedJson, Some(String::new()))
7330 .expect_err("an empty password protects nothing");
7331 assert_eq!(empty.kind(), "export_password_empty");
7332
7333 let bytes = vault
7334 .export(
7335 FfiExportFormat::EncryptedJson,
7336 Some("correct horse".to_owned()),
7337 )
7338 .expect("the export should be written");
7339 assert!(
7340 !String::from_utf8_lossy(&bytes).contains("JBSWY3DPEHPK3PXP"),
7341 "the secret must not survive in the file as it stands"
7342 );
7343
7344 let without = match FfiImport::preview(bytes.clone(), FfiImportFormat::Json, None, NOW) {
7345 Err(error) => error,
7346 Ok(_) => panic!("an encrypted export must not open without a password"),
7347 };
7348 assert_eq!(without.kind(), "import_password_required");
7349
7350 let wrong = match FfiImport::preview(
7351 bytes.clone(),
7352 FfiImportFormat::Json,
7353 Some("correct horsf".to_owned()),
7354 NOW,
7355 ) {
7356 Err(error) => error,
7357 Ok(_) => panic!("a wrong password must not open the file"),
7358 };
7359 assert_eq!(wrong.kind(), "import_wrong_password");
7360
7361 let opened = FfiImport::preview(
7362 bytes,
7363 FfiImportFormat::Json,
7364 Some("correct horse".to_owned()),
7365 NOW,
7366 )
7367 .expect("the password opens it");
7368 assert_eq!(opened.entries().len(), 1);
7369 }
7370
7371 /// A step that is not the default survives an HTML export and import.
7372 ///
7373 /// This is the pair of changes that only works together: the writer draws the step into the
7374 /// QR code, and the reader takes it back out. A default step would come back as 30 and the
7375 /// codes the user sees would never match the ones the other application shows.
7376 #[test]
7377 fn an_exported_html_file_keeps_a_step_that_is_not_the_default() {
7378 let (vault, _) = registered_vault("device-a");
7379 vault
7380 .add_entry(
7381 FfiVaultEntry {
7382 kind: FfiEntryKind::Totp { period: 90 },
7383 ..sample_entry("GitHub")
7384 },
7385 NOW,
7386 )
7387 .expect("the entry should be added");
7388
7389 let bytes = vault
7390 .export(FfiExportFormat::Html, None)
7391 .expect("the export should be written");
7392 let import = FfiImport::preview(bytes, FfiImportFormat::Html, None, NOW)
7393 .expect("the export should read back");
7394 let read = import.entries();
7395
7396 assert_eq!(read.len(), 1);
7397 assert_eq!(read[0].kind, FfiEntryKind::Totp { period: 90 });
7398 assert!(
7399 !read[0].period_assumed,
7400 "the step was in the code, so nothing was assumed"
7401 );
7402 }
7403
7404 /// Groups and their child lists cross the boundary, and the tree is what the host reads.
7405 #[test]
7406 fn groups_and_orderings_cross_the_boundary() {
7407 let (vault, _) = registered_vault("device-a");
7408 let top = "0".repeat(32);
7409
7410 let first = vault
7411 .add_entry(sample_entry("GitHub"), NOW)
7412 .expect("should add the entry");
7413 let second = vault
7414 .add_entry(sample_entry("Bank"), NOW)
7415 .expect("should add the entry");
7416
7417 // Everything starts in the top group, in the order it was added.
7418 let groups = vault.groups();
7419 assert_eq!(groups.len(), 1);
7420 assert!(groups[0].top);
7421 assert_eq!(groups[0].id, top);
7422 assert!(groups[0].name.is_empty());
7423 assert_eq!(
7424 groups[0].children,
7425 vec![
7426 FfiGroupChild::Entry { id: first.clone() },
7427 FfiGroupChild::Entry { id: second.clone() }
7428 ]
7429 );
7430 assert_eq!(groups[0].entry_ids, vec![first.clone(), second.clone()]);
7431 assert_eq!(vault.group_of(first.clone()), Some(top.clone()));
7432
7433 // A new group is appended to the top group's children and receives an entry.
7434 let work = vault
7435 .create_group("Work".to_owned())
7436 .expect("creates the group");
7437 vault
7438 .assign_to_group(first.clone(), work.clone())
7439 .expect("moves the entry");
7440 let groups = vault.groups();
7441 assert_eq!(groups.len(), 2);
7442 assert!(groups[0].top, "the top group stays first");
7443 assert_eq!(groups[1].name, "Work");
7444 assert_eq!(groups[1].entry_ids, vec![first.clone()]);
7445 assert_eq!(
7446 groups[0].entry_ids,
7447 vec![second.clone()],
7448 "the entry left the top group"
7449 );
7450 assert_eq!(vault.group_of(first.clone()), Some(work.clone()));
7451
7452 // A child group lives inside its parent, and the pre-order walk shows it there.
7453 let sub = vault
7454 .create_group_in(work.clone(), "Sub".to_owned())
7455 .expect("creates the child group");
7456 assert_eq!(vault.parent_of(sub.clone()), Some(work.clone()));
7457 assert_eq!(vault.depth_of(sub.clone()), Some(2));
7458 assert_eq!(vault.parent_of(work.clone()), Some(top.clone()));
7459 assert_eq!(
7460 vault.children_of(work.clone()),
7461 Some(vec![
7462 FfiGroupChild::Entry { id: first.clone() },
7463 FfiGroupChild::Group { id: sub.clone() }
7464 ])
7465 );
7466 let names: Vec<String> = vault.groups().into_iter().map(|group| group.name).collect();
7467 assert_eq!(
7468 names,
7469 vec![String::new(), "Work".to_owned(), "Sub".to_owned()]
7470 );
7471
7472 // A whole list is one write: the entries move to the top group, and the child group
7473 // stays where it was.
7474 vault
7475 .set_children(
7476 top.clone(),
7477 vec![
7478 FfiGroupChild::Entry { id: second.clone() },
7479 FfiGroupChild::Group { id: work.clone() },
7480 ],
7481 )
7482 .expect("the list is valid");
7483 assert_eq!(vault.parent_of(sub.clone()), Some(work.clone()));
7484
7485 // Renaming is visible in the next read; the top group has no name to change.
7486 vault
7487 .rename_group(work.clone(), "Work stuff".to_owned())
7488 .expect("renames");
7489 assert_eq!(vault.groups()[1].name, "Work stuff");
7490 assert!(
7491 vault.rename_group(top.clone(), "Nope".to_owned()).is_err(),
7492 "the top group has no name"
7493 );
7494
7495 // A group cannot be put inside its own subtree.
7496 let cycle =
7497 vault.set_children(sub.clone(), vec![FfiGroupChild::Group { id: work.clone() }]);
7498 assert!(
7499 cycle.is_err(),
7500 "a group inside its own ancestor must be refused"
7501 );
7502
7503 // Setting an ordering moves the entries it names and leaves the child groups' places.
7504 vault
7505 .set_ordering(work.clone(), vec![first.clone(), second.clone()])
7506 .expect("sets the ordering");
7507 assert_eq!(
7508 vault.groups()[1].entry_ids,
7509 vec![first.clone(), second.clone()],
7510 "the entries are where the ordering put them"
7511 );
7512
7513 // Removing a group moves its children to its parent; no entry is deleted with it.
7514 vault.remove_group(work.clone()).expect("removes the group");
7515 let groups = vault.groups();
7516 assert_eq!(groups.len(), 2, "the top group and Sub");
7517 assert_eq!(vault.entry_count(), 2);
7518 assert_eq!(
7519 groups[0].entry_ids,
7520 vec![first.clone(), second.clone()],
7521 "both entries are back in the top group"
7522 );
7523 assert_eq!(
7524 vault.parent_of(sub),
7525 Some(top),
7526 "the child group took its parent's place"
7527 );
7528 assert!(vault.remove_group("0".repeat(32)).is_err(), "the top group");
7529 }
7530
7531 /// An icon crosses the boundary with the entry and survives a round trip through the file.
7532 #[test]
7533 fn an_icon_crosses_the_boundary() {
7534 let (vault, _) = registered_vault("device-a");
7535 let icon = FfiEntryIcon {
7536 mime: FfiIconMime::Png,
7537 bytes: vec![0x89, b'P', b'N', b'G'],
7538 };
7539 let id = vault
7540 .add_entry(
7541 FfiVaultEntry {
7542 icon: Some(icon.clone()),
7543 ..sample_entry("with icon")
7544 },
7545 NOW,
7546 )
7547 .expect("the entry should be added");
7548
7549 let read = vault.entry(id.clone()).expect("the entry is there");
7550 assert_eq!(read.icon, Some(icon.clone()));
7551 let debug = format!("{read:?}");
7552 assert!(
7553 !debug.contains("137, 80, 78, 71"),
7554 "the icon bytes must not reach a log: {debug}"
7555 );
7556
7557 // The icon travels through the file and comes back unchanged.
7558 let bytes = vault.serialize().expect("should serialize");
7559 let store = Arc::new(TestStore::default());
7560 store
7561 .store_master_key(vault.lock().master_key().expose().to_vec())
7562 .expect("the key is stored");
7563 let reopened = FfiVault::open(store, bytes).expect("the file opens");
7564 assert_eq!(
7565 reopened.entry(id).expect("the entry is there").icon,
7566 Some(icon),
7567 "the picture survives the file"
7568 );
7569 }
7570
7571 /// Key material does not reach a `Debug` printout, which is where a panic message, a log
7572 /// line and a crash dump all come from.
7573 #[test]
7574 fn key_material_never_reaches_a_debug_printout() {
7575 let entry = sample_entry("GitHub");
7576 let printed = format!("{entry:?}");
7577 assert!(
7578 !printed.contains("JBSWY3DPEHPK3PXP"),
7579 "the secret must not appear: {printed}"
7580 );
7581 assert!(printed.contains("<redacted>"), "actual {printed}");
7582
7583 let auth = FfiOtpAuth {
7584 kind: FfiEntryKind::Motp {
7585 period: 10,
7586 pin: Some("5210481216086702".to_owned()),
7587 },
7588 secret_base32: "JBSWY3DPEHPK3PXP".to_owned(),
7589 issuer: None,
7590 account: "alice".to_owned(),
7591 algorithm: FfiAlgorithm::Sha1,
7592 digits: FfiDigits::Six,
7593 };
7594 let printed = format!("{auth:?}");
7595 assert!(
7596 !printed.contains("JBSWY3DPEHPK3PXP"),
7597 "the secret must not appear: {printed}"
7598 );
7599 assert!(
7600 !printed.contains("5210481216086702"),
7601 "the PIN must not appear: {printed}"
7602 );
7603 assert!(
7604 format!("{:?}", auth.kind).contains("<set>"),
7605 "the fact that a PIN is set is what an operator needs"
7606 );
7607 }
7608
7609 // ── Platform key store forwarding ────────────────────────────────
7610
7611 /// Sequence that keeps the labels of one process distinct.
7612 ///
7613 /// `cargo test` runs tests in parallel and the platform key store is global
7614 /// shared state: two tests that use one label would delete each other's keys.
7615 static PLATFORM_LABEL_SEQUENCE: std::sync::atomic::AtomicU64 =
7616 std::sync::atomic::AtomicU64::new(0);
7617
7618 /// A device label for tests, unique across the machine.
7619 ///
7620 /// The label becomes an entry in the **system-wide** key namespace, so it
7621 /// carries the `Q2faTest-` prefix the device identity kit's own integration
7622 /// tests use. A label without that prefix could name a real user identity,
7623 /// and deleting one costs that user the device identity permanently.
7624 fn test_label(name: &str) -> String {
7625 let sequence = PLATFORM_LABEL_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7626 let pid = std::process::id();
7627 format!("Q2faTest-{name}-{pid}-{sequence}")
7628 }
7629
7630 /// The label limit is enforced before the platform is touched.
7631 ///
7632 /// This holds on every machine, with or without a key store: an oversized
7633 /// label has to be rejected at the boundary and never reach the platform.
7634 #[test]
7635 fn an_oversized_label_is_rejected_before_the_platform_is_touched() {
7636 match FfiPlatformKeys::open("a".repeat(MAX_LABEL_CHARS + 1)) {
7637 Err(FfiError::InputTooLong { kind, limit, .. }) => {
7638 assert_eq!(kind, "label");
7639 assert_eq!(limit, MAX_LABEL_CHARS as u64);
7640 }
7641 Err(_) => panic!("expected an InputTooLong error, got another error kind"),
7642 Ok(_) => panic!("an oversized label must not reach the platform key store"),
7643 }
7644 }
7645
7646 /// The exported key store forwards to the platform's own key store.
7647 ///
7648 /// Requires a usable platform key store: the TPM or the software KSP on
7649 /// Windows. Where there is none — a platform whose backend is not implemented
7650 /// yet, or a machine whose key store cannot be opened — the test **skips with
7651 /// a printed reason** instead of failing, because what it checks (forwarding)
7652 /// cannot be observed without a key store. Printing the reason keeps a skip
7653 /// distinguishable from a pass in the test output.
7654 #[test]
7655 fn platform_keys_forward_to_the_platform_key_store() {
7656 let label = test_label("platform-keys");
7657 let keys = match FfiPlatformKeys::open(label.clone()) {
7658 Ok(keys) => keys,
7659 Err(error) => {
7660 println!("skipping: no usable platform key store on this machine ({error})");
7661 return;
7662 }
7663 };
7664
7665 let public_key = keys
7666 .device_public_key()
7667 .expect("the public key of the opened identity should be readable");
7668 assert_eq!(
7669 public_key.len(),
7670 65,
7671 "the public key must be a 65-byte SEC1 uncompressed point"
7672 );
7673 let public_key = qualia_dik::DevicePublicKey::from_sec1_bytes(&public_key)
7674 .expect("the platform must return a point on P-256");
7675
7676 let message = b"\x01Qualia2FA platform keys";
7677 let signature = keys.sign(message.to_vec()).expect("signing should succeed");
7678 assert_eq!(
7679 signature.len(),
7680 64,
7681 "the signature must be the 64-byte raw r||s, not DER"
7682 );
7683 let signature = qualia_dik::DeviceSignature::from_bytes(&signature)
7684 .expect("a 64-byte raw signature should parse");
7685
7686 qualia_dik::device::verify(&public_key, message, &signature)
7687 .expect("the signature must verify over the raw message, not over a digest");
7688
7689 let backend = keys.backend();
7690 assert!(
7691 backend == "tpm" || backend == "software",
7692 "an identity that opened must report a real backend, got {backend}"
7693 );
7694 assert_eq!(
7695 keys.is_hardware_backed(),
7696 backend == "tpm",
7697 "is_hardware_backed must agree with the backend that is reported"
7698 );
7699
7700 let reopened = FfiPlatformKeys::open(label.clone()).expect("reopening should succeed");
7701 assert_eq!(
7702 reopened
7703 .device_public_key()
7704 .expect("the public key should be readable again"),
7705 public_key.as_sec1_bytes().to_vec(),
7706 "the identity under one label changed: the key was not persisted"
7707 );
7708
7709 DeviceKeyStore::delete(&keys.store, label.as_str())
7710 .expect("the test identity should be removable");
7711 }
7712
7713 // ── Master key storage, both forms ───────────────────────────────
7714
7715 /// The material the master key tests store and expect back.
7716 ///
7717 /// A fixed pattern rather than random bytes: every test here compares what came back with
7718 /// what went in, and a value that changed per run would hide a wrong-bytes result behind
7719 /// a comparison that never had a chance to fail.
7720 const TEST_MASTER_KEY: [u8; MASTER_KEY_LEN] = [0x5A; MASTER_KEY_LEN];
7721
7722 /// A direct store over the software backend.
7723 ///
7724 /// The platform stores of both forms exist on Windows and Android only, so a test driving
7725 /// a real one would **skip on every other platform, CI included**, and pin nothing about
7726 /// the marshalling. The software backend is reached through exactly the methods
7727 /// production calls, and it reports `"software"` honestly.
7728 fn direct_store() -> Arc<FfiDirectMasterKeyStore> {
7729 FfiDirectMasterKeyStore::with_store(Box::new(MockDirectMasterKeyStore::new()))
7730 }
7731
7732 /// A wrapped store over the software backend.
7733 fn wrapped_store() -> Arc<FfiWrappedMasterKeyStore> {
7734 FfiWrappedMasterKeyStore::with_store(Box::new(MockWrappedMasterKeyStore::new()))
7735 }
7736
7737 /// The direct form round-trips, and deleting the key ends it.
7738 #[test]
7739 fn the_direct_master_key_round_trips_across_the_boundary() {
7740 let store = direct_store();
7741 let label = String::from("direct-master-key");
7742
7743 assert_eq!(
7744 store.backend(),
7745 "software",
7746 "the store must report the backend that answered"
7747 );
7748 assert!(
7749 !store.is_hardware_backed(),
7750 "a software backend must not be reported as hardware backed"
7751 );
7752
7753 store
7754 .store(label.clone(), TEST_MASTER_KEY.to_vec())
7755 .expect("storing the material should succeed");
7756 assert_eq!(
7757 store
7758 .load(label.clone())
7759 .expect("the material should be readable"),
7760 TEST_MASTER_KEY.to_vec(),
7761 "the direct form returned material other than what was stored"
7762 );
7763
7764 store
7765 .delete(label.clone())
7766 .expect("deleting the key should succeed");
7767 assert!(
7768 store.load(label.clone()).is_err(),
7769 "the key is still readable after it was deleted"
7770 );
7771 }
7772
7773 /// The wrapped form hands back the bytes to persist, and takes them back.
7774 #[test]
7775 fn the_wrapped_master_key_round_trips_across_the_boundary() {
7776 let store = wrapped_store();
7777 let label = String::from("wrapped-master-key");
7778
7779 assert_eq!(
7780 store.backend(),
7781 "software",
7782 "the store must report the backend that answered"
7783 );
7784 assert!(
7785 !store.is_hardware_backed(),
7786 "a software backend must not be reported as hardware backed"
7787 );
7788
7789 let wrapped = store
7790 .store(label.clone(), TEST_MASTER_KEY.to_vec())
7791 .expect("wrapping the material should succeed");
7792 assert!(
7793 !wrapped.is_empty() && wrapped != TEST_MASTER_KEY.to_vec(),
7794 "the wrapped form has to be bytes of its own, not the material"
7795 );
7796
7797 assert_eq!(
7798 store
7799 .load(label.clone(), wrapped.clone())
7800 .expect("the wrapped bytes should unwrap"),
7801 TEST_MASTER_KEY.to_vec(),
7802 "the wrapped form returned material other than what was stored"
7803 );
7804 // These bytes are what the host persists, so they have to keep working.
7805 assert_eq!(
7806 store
7807 .load(label.clone(), wrapped.clone())
7808 .expect("the wrapped bytes should unwrap again"),
7809 TEST_MASTER_KEY.to_vec(),
7810 "the wrapped bytes stopped unwrapping after a first use"
7811 );
7812
7813 store
7814 .delete(label.clone())
7815 .expect("deleting the key-encryption key should succeed");
7816 assert!(
7817 store.load(label.clone(), wrapped).is_err(),
7818 "the wrapped bytes still unwrap after the key-encryption key was deleted"
7819 );
7820 }
7821
7822 /// A damaged or foreign blob is refused, never turned into some other key.
7823 #[test]
7824 fn a_wrapped_blob_that_does_not_unwrap_is_refused() {
7825 let store = wrapped_store();
7826 let label = String::from("damaged-wrapped-key");
7827 let mut wrapped = store
7828 .store(label.clone(), TEST_MASTER_KEY.to_vec())
7829 .expect("wrapping the material should succeed");
7830
7831 // The last byte belongs to the authentication tag: changing it leaves the length and
7832 // every other byte in place, which is the shape of a blob damaged in storage.
7833 let last = wrapped.len() - 1;
7834 wrapped[last] ^= 0x01;
7835 let error = store
7836 .load(label.clone(), wrapped)
7837 .expect_err("a damaged blob must not unwrap");
7838 assert_eq!(
7839 error.kind(),
7840 "invalid_wrapped_key",
7841 "a damaged blob has to be reported as such rather than as a store failure"
7842 );
7843
7844 // A truncated blob is refused as well rather than padded into 32 bytes.
7845 let error = store
7846 .load(label.clone(), vec![0_u8; 8])
7847 .expect_err("a truncated blob must not unwrap");
7848 assert_eq!(
7849 error.kind(),
7850 "invalid_wrapped_key",
7851 "a truncated blob has to be reported as such"
7852 );
7853
7854 // Bytes from another store entry do not unwrap either: the key-encryption key
7855 // differs, so accepting them would hand back a key that merely looks valid.
7856 let other = String::from("another-wrapped-key");
7857 store
7858 .store(other.clone(), [0x11_u8; MASTER_KEY_LEN].to_vec())
7859 .expect("the second entry should wrap");
7860 let foreign = store
7861 .store(other, TEST_MASTER_KEY.to_vec())
7862 .expect("the second entry should wrap again");
7863 let error = store
7864 .load(label, foreign)
7865 .expect_err("bytes of another store entry must not unwrap");
7866 assert_eq!(
7867 error.kind(),
7868 "invalid_wrapped_key",
7869 "bytes of another store entry have to be refused"
7870 );
7871 }
7872
7873 /// Neither the material nor the blob it was wrapped into reaches an error message.
7874 #[test]
7875 fn master_key_material_never_reaches_an_error_message() {
7876 let store = wrapped_store();
7877 let label = String::from("material-in-messages");
7878 let material = [0xA5_u8; MASTER_KEY_LEN];
7879 let wrapped = store
7880 .store(label.clone(), material.to_vec())
7881 .expect("wrapping the material should succeed");
7882
7883 let mut damaged = wrapped.clone();
7884 let last = damaged.len() - 1;
7885 damaged[last] ^= 0x01;
7886 let error = store
7887 .load(label, damaged)
7888 .expect_err("a damaged blob must not unwrap");
7889
7890 let rendered = format!("{error} | {error:?}");
7891 for leak in [
7892 to_hex(&material),
7893 format!("{material:?}"),
7894 to_hex(&wrapped),
7895 format!("{wrapped:?}"),
7896 ] {
7897 assert!(
7898 !rendered.contains(&leak),
7899 "an error message carries master key material or the blob wrapping it: {rendered}"
7900 );
7901 }
7902 }
7903
7904 /// The boundary's own limits are enforced before anything else is done with the input.
7905 #[test]
7906 fn an_oversized_label_is_rejected_before_the_master_key_store_is_reached() {
7907 let store = direct_store();
7908 // Both inputs are wrong. If the label limit did not come first, the report would be
7909 // about the key length instead, and a label the store forbids would have been passed
7910 // on to be judged there.
7911 let error = store
7912 .store("a".repeat(MAX_LABEL_CHARS + 1), vec![0_u8; 1])
7913 .expect_err("an oversized label must be rejected");
7914 assert_eq!(error.kind(), "input_too_long");
7915 }
7916
7917 /// A master key of the wrong length is refused with the core's own kind.
7918 #[test]
7919 fn a_master_key_of_the_wrong_length_is_refused() {
7920 let store = direct_store();
7921 let error = store
7922 .store(
7923 String::from("short-master-key"),
7924 vec![0_u8; MASTER_KEY_LEN - 1],
7925 )
7926 .expect_err("a short master key must be refused");
7927 assert_eq!(
7928 error.kind(),
7929 "master_key_wrong_length",
7930 "a wrong length has to be reported with the kind the vault paths already use"
7931 );
7932 }
7933
7934 // ── Synchronization across the boundary ──────────────────────────
7935
7936 /// A channel for tests: two queues and no protocol of its own.
7937 ///
7938 /// It makes no decisions on purpose. A channel that de-duplicated, reordered or
7939 /// retried would hide exactly the defects these tests are looking for, because every
7940 /// protocol rule is supposed to live in the core.
7941 struct MemoryChannel {
7942 /// This device's identifier, hexadecimal.
7943 me: String,
7944 /// The capabilities this channel declares.
7945 caps: FfiTransportCapabilities,
7946 /// Outbound queue: `(recipient, bytes)`.
7947 outbox: std::sync::Mutex<Vec<(FfiRecipient, Vec<u8>)>>,
7948 /// Inbound queue, with the sender the channel claims for each message.
7949 inbox: std::sync::Mutex<Vec<FfiReceivedMessage>>,
7950 /// Peers this channel claims.
7951 peers: Vec<String>,
7952 }
7953
7954 impl MemoryChannel {
7955 /// An empty two-party channel for the device with this identifier.
7956 fn new(me: String) -> Self {
7957 Self {
7958 me,
7959 caps: FfiTransportCapabilities {
7960 roster: FfiRoster::TwoParty,
7961 // The code channel of the application is text: the user carries the
7962 // payload by hand, so it has no per-message limit.
7963 max_message_len: None,
7964 max_qr_version: None,
7965 single_use: false,
7966 can_carry_payload: true,
7967 },
7968 outbox: std::sync::Mutex::new(Vec::new()),
7969 inbox: std::sync::Mutex::new(Vec::new()),
7970 peers: Vec::new(),
7971 }
7972 }
7973
7974 /// Moves this channel's outbound queue into another channel's inbound queue.
7975 ///
7976 /// The outbound queue is **not** cleared, so one payload can be delivered again —
7977 /// which is how the replay cases are exercised.
7978 ///
7979 /// With `honest` set to `false` the claimed sender is replaced by this channel's
7980 /// own identifier, which models a third party handing over somebody else's payload.
7981 fn deliver_to(&self, target: &Self, honest: bool) {
7982 let outbox = self.outbox.lock().expect("no lock poisoning in tests");
7983 let mut inbox = target.inbox.lock().expect("no lock poisoning in tests");
7984 for (recipient, bytes) in outbox.iter() {
7985 let addressed = match recipient {
7986 FfiRecipient::All => true,
7987 FfiRecipient::One { device_id } => *device_id == target.me,
7988 };
7989 if !addressed {
7990 continue;
7991 }
7992 inbox.push(FfiReceivedMessage {
7993 sender: if honest {
7994 self.me.clone()
7995 } else {
7996 target.me.clone()
7997 },
7998 message: bytes.clone(),
7999 });
8000 }
8001 }
8002
8003 /// Discards every message in the outbound queue.
8004 fn clear_outbox(&self) {
8005 self.outbox
8006 .lock()
8007 .expect("no lock poisoning in tests")
8008 .clear();
8009 }
8010
8011 /// Pushes a message straight into the inbound queue, as an attacker would.
8012 fn inject(&self, claimed_sender: String, message: Vec<u8>) {
8013 self.inbox
8014 .lock()
8015 .expect("no lock poisoning in tests")
8016 .push(FfiReceivedMessage {
8017 sender: claimed_sender,
8018 message,
8019 });
8020 }
8021 }
8022
8023 impl FfiTransport for MemoryChannel {
8024 fn capabilities(&self) -> FfiTransportCapabilities {
8025 self.caps
8026 }
8027
8028 fn local_device(&self) -> String {
8029 self.me.clone()
8030 }
8031
8032 fn send(&self, to: FfiRecipient, message: Vec<u8>) -> Result<(), FfiError> {
8033 self.outbox
8034 .lock()
8035 .expect("no lock poisoning in tests")
8036 .push((to, message));
8037 Ok(())
8038 }
8039
8040 fn receive(&self) -> Result<Option<FfiReceivedMessage>, FfiError> {
8041 let mut inbox = self.inbox.lock().expect("no lock poisoning in tests");
8042 if inbox.is_empty() {
8043 // "Nothing available" is a state, not a failure.
8044 return Ok(None);
8045 }
8046 Ok(Some(inbox.remove(0)))
8047 }
8048
8049 fn peers(&self) -> Result<Vec<String>, FfiError> {
8050 Ok(self.peers.clone())
8051 }
8052 }
8053
8054 /// A device identifier for tests: one hexadecimal digit repeated to 64 characters.
8055 fn test_device_id(digit: char) -> String {
8056 std::iter::repeat_n(digit, 64).collect()
8057 }
8058
8059 /// Registers a vault and hands back the store holding its master key.
8060 fn registered_vault(label: &str) -> (Arc<FfiVault>, Arc<TestStore>) {
8061 let store = Arc::new(TestStore::default());
8062 let vault = FfiVault::register(
8063 Arc::new(TestKeys::default()),
8064 store.clone(),
8065 label.to_owned(),
8066 )
8067 .expect("registration should succeed");
8068 (vault, store)
8069 }
8070
8071 /// The second device of the same account: the same master key, its own empty vault.
8072 fn vault_of_the_same_account(store: &TestStore) -> Arc<FfiVault> {
8073 let master = store
8074 .master
8075 .lock()
8076 .expect("no lock poisoning in tests")
8077 .clone()
8078 .expect("the account is registered, so a master key exists");
8079 FfiVault::from_master_key(master).expect("32 bytes are a master key")
8080 }
8081
8082 /// Two devices of one account, both holding the same entry.
8083 struct TwoDevices {
8084 /// The first device's vault.
8085 vault_a: Arc<FfiVault>,
8086 /// The second device's vault.
8087 vault_b: Arc<FfiVault>,
8088 /// The first device's session.
8089 session_a: Arc<FfiSyncSession>,
8090 /// The second device's session.
8091 session_b: Arc<FfiSyncSession>,
8092 /// The first device's channel.
8093 channel_a: Arc<MemoryChannel>,
8094 /// The second device's channel.
8095 channel_b: Arc<MemoryChannel>,
8096 /// The identifier the two devices share.
8097 entry_id: String,
8098 }
8099
8100 impl TwoDevices {
8101 /// Registers A, gives B the same master key, and syncs one entry from A to B.
8102 fn with_one_shared_entry() -> Self {
8103 let (vault_a, store_a) = registered_vault("device-a");
8104 let vault_b = vault_of_the_same_account(&store_a);
8105 let entry_id = vault_a
8106 .add_entry(sample_entry("GitHub"), NOW)
8107 .expect("the entry should be added");
8108
8109 let devices = Self {
8110 session_a: FfiSyncSession::open(vault_a.clone()),
8111 session_b: FfiSyncSession::open(vault_b.clone()),
8112 channel_a: Arc::new(MemoryChannel::new(test_device_id('a'))),
8113 channel_b: Arc::new(MemoryChannel::new(test_device_id('b'))),
8114 vault_a,
8115 vault_b,
8116 entry_id,
8117 };
8118
8119 let outcome = devices.sync_a_to_b();
8120 assert_eq!(outcome.added, 1, "precondition: B receives the entry");
8121 devices.channel_a.clear_outbox();
8122 devices
8123 }
8124
8125 /// Sends A's state to B's channel and merges it there.
8126 fn sync_a_to_b(&self) -> FfiSyncOutcome {
8127 self.session_a
8128 .send(
8129 self.channel_a.clone(),
8130 FfiRecipient::All,
8131 FfiPayloadKind::Vault,
8132 )
8133 .expect("sending should succeed");
8134 self.channel_a.deliver_to(&self.channel_b, true);
8135 self.session_b
8136 .receive(self.channel_b.clone())
8137 .expect("receiving should succeed")
8138 }
8139 }
8140
8141 /// **The criterion for the whole feature**: two vaults exchange their entries through a
8142 /// channel, in one process.
8143 ///
8144 /// Asserting "no error" would not be enough: a session that sends an empty payload and
8145 /// merges nothing reports no error either. What is asserted is that the entry really
8146 /// arrives and keeps its identifier, that the way back changes nothing, and that a
8147 /// re-delivered payload is refused.
8148 #[test]
8149 fn two_vaults_complete_a_sync_across_the_boundary() {
8150 let (vault_a, store_a) = registered_vault("device-a");
8151 let vault_b = vault_of_the_same_account(&store_a);
8152 let entry_id = vault_a
8153 .add_entry(sample_entry("GitHub"), NOW)
8154 .expect("the entry should be added");
8155 assert_eq!(vault_b.entry_count(), 0, "precondition: B starts empty");
8156
8157 let session_a = FfiSyncSession::open(vault_a.clone());
8158 let session_b = FfiSyncSession::open(vault_b.clone());
8159 let channel_a = Arc::new(MemoryChannel::new(test_device_id('a')));
8160 let channel_b = Arc::new(MemoryChannel::new(test_device_id('b')));
8161
8162 let plan = session_a
8163 .plan_share(channel_a.clone())
8164 .expect("planning should succeed");
8165 assert!(
8166 matches!(plan, FfiSharePlan::FullPayload { len } if len > 0),
8167 "a vault with one entry plans as a full payload: {plan:?}"
8168 );
8169
8170 let sync_id = session_a
8171 .send(channel_a.clone(), FfiRecipient::All, FfiPayloadKind::Vault)
8172 .expect("sending should succeed");
8173 assert_eq!(sync_id.len(), 64, "a payload identifier is 32 bytes in hex");
8174
8175 channel_a.deliver_to(&channel_b, true);
8176 let outcome = session_b
8177 .receive(channel_b.clone())
8178 .expect("receiving should succeed");
8179 assert_eq!(outcome.added, 1, "the entry must arrive: {outcome:?}");
8180 assert!(
8181 !outcome.needs_adjudication,
8182 "an ordinary sync asks the user nothing: {outcome:?}"
8183 );
8184 assert!(!outcome.made_no_change, "something did change: {outcome:?}");
8185 assert_eq!(vault_b.entry_count(), 1);
8186 let arrived = vault_b.entries();
8187 assert_eq!(arrived[0].name, "GitHub");
8188 assert_eq!(
8189 arrived[0].id, entry_id,
8190 "the entry keeps its identifier, which is the only thing the two devices agree on"
8191 );
8192
8193 let replay = session_b
8194 .receive(channel_b.clone())
8195 .expect_err("nothing is queued any more, so the channel reports no session");
8196 assert_eq!(replay.kind(), "sync_no_session_found");
8197
8198 // Delivering the very same bytes again is a replay, and is refused.
8199 channel_a.deliver_to(&channel_b, true);
8200 let replay = session_b
8201 .receive(channel_b.clone())
8202 .expect_err("the same payload must not be applied twice");
8203 assert_eq!(replay.kind(), "sync_already_applied");
8204
8205 session_b
8206 .send(channel_b.clone(), FfiRecipient::All, FfiPayloadKind::Vault)
8207 .expect("sending back should succeed");
8208 channel_b.deliver_to(&channel_a, true);
8209 let back = session_a
8210 .receive(channel_a.clone())
8211 .expect("receiving should succeed");
8212 assert!(
8213 back.made_no_change,
8214 "A already holds everything B has: {back:?}"
8215 );
8216 assert!(!back.needs_adjudication);
8217 }
8218
8219 /// A payload that claims a different sender than the channel reports is refused.
8220 ///
8221 /// This is the third-party case: the payload is cryptographically valid, so only the
8222 /// channel's own report can tell that somebody else handed it over.
8223 #[test]
8224 fn a_payload_whose_sender_disagrees_with_the_channel_is_refused() {
8225 let devices = TwoDevices::with_one_shared_entry();
8226 let mut second_entry = sample_entry("GitLab");
8227 second_entry.name = "GitLab".to_owned();
8228 devices
8229 .vault_a
8230 .add_entry(second_entry, NOW + 10)
8231 .expect("the entry should be added");
8232
8233 devices
8234 .session_a
8235 .send(
8236 devices.channel_a.clone(),
8237 FfiRecipient::All,
8238 FfiPayloadKind::Vault,
8239 )
8240 .expect("sending should succeed");
8241 devices.channel_a.deliver_to(&devices.channel_b, false);
8242
8243 let error = devices
8244 .session_b
8245 .receive(devices.channel_b.clone())
8246 .expect_err("a misreported sender must be refused");
8247 assert_eq!(error.kind(), "sync_sender_mismatch");
8248 assert_eq!(
8249 devices.vault_b.entry_count(),
8250 1,
8251 "a refused payload must not modify the vault"
8252 );
8253 }
8254
8255 /// A device identifier that is not hexadecimal is refused before anything is sent.
8256 #[test]
8257 fn an_identifier_that_is_not_hexadecimal_is_refused() {
8258 let (vault, _) = registered_vault("device-a");
8259 let session = FfiSyncSession::open(vault);
8260 let channel = Arc::new(MemoryChannel::new("not a device identifier".to_owned()));
8261
8262 let error = session
8263 .send(channel, FfiRecipient::All, FfiPayloadKind::Vault)
8264 .expect_err("the identifier must be rejected");
8265 assert_eq!(error.kind(), "invalid_device_id");
8266 }
8267
8268 /// A decision for a conflict that is not waiting is refused rather than applied to
8269 /// whatever now sits under that identifier.
8270 #[test]
8271 fn a_decision_for_a_conflict_that_is_not_waiting_is_refused() {
8272 let (vault, _) = registered_vault("device-a");
8273 let session = FfiSyncSession::open(vault);
8274
8275 let error = session
8276 .adjudicate("0".repeat(32), FfiDecision::KeepLocal)
8277 .expect_err("nothing is waiting for this identifier");
8278 assert_eq!(error.kind(), "unknown_conflict");
8279 }
8280
8281 /// A deletion the peer reports about an entry **edited here** is a decision for a human.
8282 ///
8283 /// The two directions of "one side deleted it" are pinned separately: here the peer
8284 /// deleted and this device edited afterwards; the nested test covers the other one.
8285 #[test]
8286 fn a_peer_deletion_of_an_edited_entry_is_reported_and_closed_by_the_decision() {
8287 let devices = TwoDevices::with_one_shared_entry();
8288
8289 let mut edited = sample_entry("GitHub (work)");
8290 edited.id = devices.entry_id.clone();
8291 devices
8292 .vault_b
8293 .update_entry(edited, NOW + 120)
8294 .expect("the edit should be accepted");
8295 devices
8296 .vault_a
8297 .remove_entry(devices.entry_id.clone(), NOW + 60)
8298 .expect("the deletion should be accepted");
8299
8300 let outcome = devices.sync_a_to_b();
8301 assert!(
8302 outcome.needs_adjudication,
8303 "a deletion against an edited entry must be reported: {outcome:?}"
8304 );
8305 assert_eq!(
8306 outcome.deletions_applied, 0,
8307 "nothing may be deleted before the user decides"
8308 );
8309 assert_eq!(
8310 devices.vault_b.entry_count(),
8311 1,
8312 "the entry is still there until the decision"
8313 );
8314 assert_eq!(outcome.conflicts.len(), 1);
8315 let conflict = &outcome.conflicts[0];
8316 assert_eq!(conflict.kind, "peer_deleted");
8317 assert_eq!(conflict.entry_id, devices.entry_id);
8318 assert_eq!(
8319 conflict
8320 .local
8321 .as_ref()
8322 .expect("the local version is shown")
8323 .name,
8324 "GitHub (work)"
8325 );
8326 assert!(
8327 conflict.incoming.is_none(),
8328 "the peer sent a tombstone, which has nothing to show"
8329 );
8330 assert!(
8331 devices.session_b.pending_conflicts().len() == 1,
8332 "the conflict stays available until it is decided"
8333 );
8334
8335 devices
8336 .session_b
8337 .adjudicate(devices.entry_id.clone(), FfiDecision::TakeIncoming)
8338 .expect("the decision should apply");
8339 assert_eq!(devices.vault_b.entry_count(), 0, "the deletion is applied");
8340 assert_eq!(devices.vault_b.tombstone_count(), 1);
8341 assert!(
8342 devices.session_b.pending_conflicts().is_empty(),
8343 "a decided conflict stops being pending"
8344 );
8345
8346 devices.channel_a.clear_outbox();
8347 let settled = devices.sync_a_to_b();
8348 assert!(
8349 !settled.needs_adjudication,
8350 "the decision must close the conflict: {settled:?}"
8351 );
8352 }
8353
8354 /// An entry deleted **here** that the peer still holds can be kept deleted.
8355 #[test]
8356 fn a_local_deletion_can_be_confirmed_against_a_peer_that_still_has_the_entry() {
8357 let devices = TwoDevices::with_one_shared_entry();
8358 devices
8359 .vault_b
8360 .remove_entry(devices.entry_id.clone(), NOW + 60)
8361 .expect("the deletion should be accepted");
8362
8363 let outcome = devices.sync_a_to_b();
8364 assert!(outcome.needs_adjudication, "{outcome:?}");
8365 let conflict = &outcome.conflicts[0];
8366 assert_eq!(conflict.kind, "local_deleted");
8367 assert!(
8368 conflict.local.is_none(),
8369 "this device holds only a tombstone, so there is no local version to show"
8370 );
8371 assert_eq!(
8372 conflict
8373 .incoming
8374 .as_ref()
8375 .expect("the peer's version is shown")
8376 .name,
8377 "GitHub"
8378 );
8379
8380 devices
8381 .session_b
8382 .adjudicate(devices.entry_id.clone(), FfiDecision::KeepLocal)
8383 .expect("the decision should apply");
8384 assert_eq!(devices.vault_b.entry_count(), 0, "the entry stays deleted");
8385 assert!(
8386 devices.vault_b.tombstone_count() == 1,
8387 "the deletion is still recorded, so it keeps travelling"
8388 );
8389 }
8390
8391 /// The same conflict, decided the other way: the entry comes back.
8392 ///
8393 /// This is the case that needs the tombstone to be dropped: keeping it next to a live
8394 /// entry would let the payload builder send the tombstone on its own, and the peer would
8395 /// delete the entry again.
8396 #[test]
8397 fn an_entry_deleted_here_can_be_restored_from_the_peer() {
8398 let devices = TwoDevices::with_one_shared_entry();
8399 devices
8400 .vault_b
8401 .remove_entry(devices.entry_id.clone(), NOW + 60)
8402 .expect("the deletion should be accepted");
8403
8404 let outcome = devices.sync_a_to_b();
8405 assert_eq!(outcome.conflicts[0].kind, "local_deleted");
8406 devices
8407 .session_b
8408 .adjudicate(devices.entry_id.clone(), FfiDecision::TakeIncoming)
8409 .expect("the decision should apply");
8410
8411 assert_eq!(devices.vault_b.entry_count(), 1, "the entry is back");
8412 assert_eq!(
8413 devices.vault_b.tombstone_count(),
8414 0,
8415 "the tombstone must be gone, or the peer would delete the entry again"
8416 );
8417 assert_eq!(
8418 devices
8419 .vault_b
8420 .entry(devices.entry_id.clone())
8421 .expect("the entry is readable")
8422 .name,
8423 "GitHub"
8424 );
8425
8426 devices.channel_a.clear_outbox();
8427 let settled = devices.sync_a_to_b();
8428 assert!(
8429 !settled.needs_adjudication,
8430 "the decision must close the conflict: {settled:?}"
8431 );
8432 assert!(settled.made_no_change, "both sides now hold the same entry");
8433 }
8434
8435 /// A payload that authenticates but is not a payload at all is refused without touching
8436 /// the vault, and the error says which layer rejected it.
8437 #[test]
8438 fn a_garbage_payload_is_refused_across_the_boundary() {
8439 let devices = TwoDevices::with_one_shared_entry();
8440 devices
8441 .channel_b
8442 .inject(test_device_id('a'), vec![0_u8; 200]);
8443
8444 let error = devices
8445 .session_b
8446 .receive(devices.channel_b.clone())
8447 .expect_err("random bytes are not a payload");
8448 assert_eq!(
8449 error.kind(),
8450 "sync_bad_magic",
8451 "the header is checked before anything is decrypted"
8452 );
8453 assert_eq!(devices.vault_b.entry_count(), 1, "the vault is untouched");
8454 }
8455
8456 /// A payload too large for one code travels as several, collected across the boundary.
8457 ///
8458 /// The frame images are read back through the same entry point a camera's picture goes
8459 /// through, so what is asserted is the whole path: draw the frames, take their text out of
8460 /// the pixels, hand them over in the wrong order and with a repeat, and get back the
8461 /// payload that went in.
8462 #[test]
8463 fn a_payload_too_large_for_one_code_travels_as_several() {
8464 let payload = format!("otpauth://totp/example?secret={}", "A".repeat(3000));
8465 let frames = qr_encode_frames(payload.clone(), FfiEcLevel::Medium, 20, 32, 4, 4)
8466 .expect("the frames should be drawn");
8467 assert!(
8468 frames.len() > 1,
8469 "3000 bytes must need more than one code, not {}",
8470 frames.len()
8471 );
8472
8473 let capacity = qr_frame_capacity(FfiEcLevel::Medium, 20).expect("a capacity");
8474 let collector = FfiFrameCollector::new(capacity);
8475 let mut order: Vec<&FfiQrFrame> = frames.iter().rev().collect();
8476 order.push(frames.first().expect("at least one frame"));
8477
8478 let mut completed: Option<Vec<u8>> = None;
8479 for frame in order {
8480 let text = qr_decode_text(
8481 frame.image.pixels.clone(),
8482 frame.image.width,
8483 frame.image.height,
8484 )
8485 .expect("a frame's own image must be readable");
8486 let step = collector.push(text).expect("a frame of this application");
8487 assert!(step.index >= 1 && step.index <= step.count);
8488 if let Some(bytes) = step.payload {
8489 completed = Some(bytes);
8490 }
8491 }
8492
8493 assert_eq!(
8494 String::from_utf8(completed.expect("the set completes")).expect("UTF-8 text"),
8495 payload
8496 );
8497 assert!(collector.missing().is_empty(), "nothing is left missing");
8498 }
8499
8500 // ── The LAN channel ──────────────────────────────────────────────────
8501
8502 /// A device identifier in the form this boundary uses, derived from a seed.
8503 fn lan_device(seed: u8) -> String {
8504 DeviceId::from_bytes([seed; 32]).to_hex()
8505 }
8506
8507 #[test]
8508 fn a_socket_address_round_trips_and_a_wrong_one_is_rejected() {
8509 let socket = parse_socket("192.168.1.5:41234").expect("a printed address must parse");
8510 assert_eq!(socket.to_string(), "192.168.1.5:41234");
8511 assert_eq!(
8512 parse_socket("192.168.1.5")
8513 .expect_err("a missing port is not an address")
8514 .kind(),
8515 "bad_socket"
8516 );
8517 assert_eq!(
8518 parse_socket("not an address")
8519 .expect_err("text is not an address")
8520 .kind(),
8521 "bad_socket"
8522 );
8523 }
8524
8525 /// The LAN kinds are distinct and are the ones the host side branches on.
8526 ///
8527 /// The transport's error enum is `#[non_exhaustive]`, so an unmapped variant lands in
8528 /// `lan_error` without the compiler saying anything. This list is what says it.
8529 #[test]
8530 fn every_lan_error_kind_is_distinct_and_known() {
8531 use qualia_transport::Error as LanError;
8532
8533 let cases = [
8534 (LanError::NoConnectionToPeer, "no_connection_to_peer"),
8535 (LanError::TransportEmpty, "transport_empty"),
8536 (
8537 LanError::PayloadTooLarge {
8538 needed: 10,
8539 limit: 1,
8540 },
8541 "payload_too_large",
8542 ),
8543 (LanError::NoLanPeersFound, "no_lan_peers_found"),
8544 (LanError::LanSocket { operation: "bind" }, "lan_socket"),
8545 (
8546 LanError::LanTransport {
8547 operation: "connect",
8548 },
8549 "lan_transport",
8550 ),
8551 (LanError::NotOurLanAddress, "not_our_lan_address"),
8552 (
8553 LanError::BadLanAddressLength {
8554 expected: 42,
8555 actual: 4,
8556 },
8557 "bad_lan_address_length",
8558 ),
8559 (LanError::ZeroPort, "zero_port"),
8560 ];
8561
8562 let mut seen = std::collections::HashSet::new();
8563 for (source, expected) in cases {
8564 assert_eq!(lan_error(source).kind(), expected);
8565 assert!(seen.insert(expected), "{expected} is mapped twice");
8566 }
8567 }
8568
8569 /// The channel answers the trait's questions, and answers them the same way through the
8570 /// trait object a session is handed.
8571 #[test]
8572 fn the_lan_capabilities_describe_a_reusable_stream() {
8573 let Ok(channel) = FfiLanChannel::open(lan_device(1)) else {
8574 return;
8575 };
8576
8577 let caps = channel.capabilities();
8578 assert_eq!(caps.roster, FfiRoster::MultiParty);
8579 assert_eq!(caps.max_message_len, Some(MAX_MESSAGE_LEN as u64));
8580 assert_eq!(caps.max_qr_version, None, "this channel displays no code");
8581 assert!(!caps.single_use, "a connection is not a one-shot artifact");
8582 assert!(caps.can_carry_payload);
8583
8584 assert_eq!(channel.local_device(), lan_device(1));
8585 assert!(
8586 channel
8587 .peers()
8588 .expect("the peer table is readable")
8589 .is_empty(),
8590 "a fresh channel has no peers"
8591 );
8592 assert!(
8593 channel
8594 .receive()
8595 .expect("an empty inbox is not a failure")
8596 .is_none(),
8597 "nothing has been sent to this channel"
8598 );
8599
8600 let transport = channel.transport();
8601 assert_eq!(transport.local_device(), lan_device(1));
8602 assert_eq!(transport.capabilities().roster, FfiRoster::MultiParty);
8603 assert_eq!(
8604 transport.capabilities().max_message_len,
8605 Some(MAX_MESSAGE_LEN as u64)
8606 );
8607 assert!(
8608 transport
8609 .peers()
8610 .expect("the peer table is readable")
8611 .is_empty()
8612 );
8613
8614 channel.shutdown();
8615 }
8616
8617 #[test]
8618 fn the_record_is_the_transports_own_connection_record() {
8619 let Ok(channel) = FfiLanChannel::open(lan_device(2)) else {
8620 return;
8621 };
8622
8623 let record = channel.record().expect("an open endpoint has a record");
8624 let address = LanAddress::decode(&record).expect("a record this layer built must decode");
8625 assert_eq!(address.port, channel.inner.port().expect("a bound port"));
8626 assert_eq!(address.endpoint_key, channel.inner.endpoint_key());
8627
8628 channel.shutdown();
8629 }
8630
8631 #[test]
8632 fn a_peer_must_be_registered_before_it_can_be_sent_to() {
8633 let Ok(channel) = FfiLanChannel::open(lan_device(3)) else {
8634 return;
8635 };
8636
8637 let error = channel
8638 .send(
8639 FfiRecipient::One {
8640 device_id: lan_device(4),
8641 },
8642 b"hello".to_vec(),
8643 )
8644 .expect_err("a peer that was never registered has no connection");
8645 assert_eq!(error.kind(), "no_connection_to_peer");
8646
8647 assert!(
8648 channel.send(FfiRecipient::All, b"hello".to_vec()).is_ok(),
8649 "a broadcast with nobody registered is accepted and reaches nobody"
8650 );
8651
8652 channel.shutdown();
8653 }
8654
8655 #[test]
8656 fn registering_a_peer_takes_a_record_and_an_address() {
8657 let Ok(channel) = FfiLanChannel::open(lan_device(5)) else {
8658 return;
8659 };
8660 let Ok(other) = FfiLanChannel::open(lan_device(6)) else {
8661 return;
8662 };
8663
8664 let record = other.record().expect("a record");
8665 let socket = format!("127.0.0.1:{}", other.inner.port().expect("a port"));
8666 channel
8667 .remember_peer(lan_device(6), record.clone(), socket.clone())
8668 .expect("registering a peer with a real record must work");
8669 assert_eq!(
8670 channel.peers().expect("the peer table is readable"),
8671 vec![lan_device(6)]
8672 );
8673
8674 assert_eq!(
8675 channel
8676 .remember_peer(lan_device(6), vec![0; 4], socket.clone())
8677 .expect_err("four bytes are not a connection record")
8678 .kind(),
8679 "not_our_lan_address"
8680 );
8681 assert_eq!(
8682 channel
8683 .remember_peer(lan_device(6), record.clone(), "not an address".to_owned())
8684 .expect_err("a socket address is `ip:port`")
8685 .kind(),
8686 "bad_socket"
8687 );
8688 assert_eq!(
8689 channel
8690 .remember_peer("nope".to_owned(), record.clone(), socket)
8691 .expect_err("an identifier is 64 hexadecimal characters")
8692 .kind(),
8693 "invalid_device_id"
8694 );
8695 assert_eq!(
8696 channel.peers().expect("the peer table is readable"),
8697 vec![lan_device(6)],
8698 "a rejected registration must not change the peer table"
8699 );
8700
8701 channel
8702 .forget_peer(lan_device(6))
8703 .expect("forgetting works");
8704 assert!(
8705 channel
8706 .peers()
8707 .expect("the peer table is readable")
8708 .is_empty()
8709 );
8710 assert_eq!(
8711 channel
8712 .forget_peer("nope".to_owned())
8713 .expect_err("a malformed identifier is refused")
8714 .kind(),
8715 "invalid_device_id"
8716 );
8717
8718 other.shutdown();
8719 channel.shutdown();
8720 }
8721
8722 /// Two channels on this machine exchange one message, sender included.
8723 ///
8724 /// Loopback is the only requirement: the addresses come from the endpoints themselves, so
8725 /// nothing here needs a real network segment. This is also what pins the sender claim — the
8726 /// field the core compares with the sender inside a payload.
8727 #[test]
8728 fn two_channels_exchange_a_message_over_loopback() {
8729 let Ok(a) = FfiLanChannel::open(lan_device(7)) else {
8730 return;
8731 };
8732 let Ok(b) = FfiLanChannel::open(lan_device(8)) else {
8733 return;
8734 };
8735
8736 let record = b.record().expect("a record");
8737 // The port in this address is deliberately wrong, and only its IP is meant to matter:
8738 // the record carries the port the endpoint listens on, while the address a datagram
8739 // arrives from carries the lobby's. A caller that hands over what it saw must still
8740 // end up dialing the endpoint.
8741 a.remember_peer(lan_device(8), record, "127.0.0.1:1".to_owned())
8742 .expect("registering a peer must work");
8743
8744 a.send(
8745 FfiRecipient::One {
8746 device_id: lan_device(8),
8747 },
8748 b"the payload".to_vec(),
8749 )
8750 .expect("the send must be accepted");
8751
8752 let received = (0..200)
8753 .find_map(|_| {
8754 std::thread::sleep(std::time::Duration::from_millis(25));
8755 b.receive().expect("receiving must not fail")
8756 })
8757 .expect("the message must arrive within five seconds");
8758 assert_eq!(received.sender, lan_device(7), "the sender is the claim");
8759 assert_eq!(received.message, b"the payload");
8760
8761 a.shutdown();
8762 b.shutdown();
8763 }
8764
8765 /// A discovered peer is offered at the endpoint's own port, not at the port its
8766 /// announcement was sent from.
8767 ///
8768 /// The two differ whenever the lobby is in use — announcements leave the lobby's socket,
8769 /// which is bound to the multicast port — and dialing the sender's port reaches a socket
8770 /// that speaks the announcement format and never answers a connection.
8771 #[test]
8772 fn a_discovered_peer_is_offered_at_the_endpoints_own_port() {
8773 let device = DeviceId::from_bytes([7; 32]);
8774 let peer = Peer {
8775 device,
8776 endpoint_key: EndpointKey::from_bytes([8; 32]),
8777 port: 41_007,
8778 source: Some(SocketAddr::from(([192, 168, 1, 7], 21_377))),
8779 status: PeerStatus::Here,
8780 last_seen_ms: 0,
8781 sequence: 0,
8782 };
8783
8784 let offered = lan_peer_of(peer);
8785 assert_eq!(offered.device_id, device.to_hex());
8786 assert_eq!(
8787 offered.socket.as_deref(),
8788 Some("192.168.1.7:41007"),
8789 "the IP comes from the datagram and the port from the announcement"
8790 );
8791 assert_eq!(
8792 LanAddress::decode(&offered.record)
8793 .expect("a record this layer built must decode")
8794 .port,
8795 41_007
8796 );
8797 assert!(offered.here);
8798
8799 let source_unknown = lan_peer_of(Peer {
8800 source: None,
8801 ..peer
8802 });
8803 assert_eq!(
8804 source_unknown.socket, None,
8805 "a peer whose announcement had no source can be shown but not dialed"
8806 );
8807 }
8808
8809 #[test]
8810 fn a_lobby_reports_its_view_and_stops_cleanly() {
8811 let Ok(channel) = FfiLanChannel::open(lan_device(9)) else {
8812 return;
8813 };
8814 let Ok(lobby) = channel.open_lobby() else {
8815 return;
8816 };
8817
8818 assert!(
8819 lobby.peers().is_empty(),
8820 "a lobby that just opened has heard nobody"
8821 );
8822 lobby
8823 .announce_now()
8824 .expect("announcing must reach the socket");
8825 lobby.shutdown();
8826 lobby.shutdown();
8827
8828 channel.shutdown();
8829 }
8830
8831 /// The password wrap round trips across the boundary and reports a wrong password.
8832 #[test]
8833 fn lock_password_wrap_roundtrips() {
8834 let master = MasterKey::generate().expect("randomness should be available");
8835 let master_bytes = flow::master_key_to_bytes(&master).expose_secret().to_vec();
8836
8837 let file = wrap_master_key_with_password(master_bytes.clone(), "correct horse".to_owned())
8838 .expect("wrapping should succeed");
8839 assert_eq!(
8840 file.len() as u64,
8841 lock_wrap_len(),
8842 "the exposed length must match what the wrap returns"
8843 );
8844
8845 let restored = unwrap_master_key_with_password(file.clone(), "correct horse".to_owned())
8846 .expect("the right password must open it");
8847 assert_eq!(restored, master_bytes);
8848
8849 let error = unwrap_master_key_with_password(file, "wrong password".to_owned())
8850 .expect_err("a wrong password must be rejected");
8851 assert_eq!(error.kind(), "lock_password_rejected");
8852 }
8853
8854 /// Empty and oversized passwords are rejected at the boundary.
8855 #[test]
8856 fn lock_password_limits_are_enforced() {
8857 let master = MasterKey::generate().expect("randomness should be available");
8858 let master_bytes = flow::master_key_to_bytes(&master).expose_secret().to_vec();
8859
8860 let error = wrap_master_key_with_password(master_bytes.clone(), String::new())
8861 .expect_err("an empty password must be rejected");
8862 assert_eq!(error.kind(), "lock_passphrase_empty");
8863
8864 let oversized = "x".repeat(MAX_PASSPHRASE_CHARS + 1);
8865 let error = wrap_master_key_with_password(master_bytes, oversized)
8866 .expect_err("an oversized password must be rejected");
8867 assert_eq!(error.kind(), "input_too_long");
8868 }
8869
8870 /// The state helpers build and recognize the two marker files.
8871 #[test]
8872 fn lock_state_files_roundtrip() {
8873 let marker = lock_marker(true, false, 1_700_000_000).expect("marker");
8874 assert!(is_lock_marker(marker.clone()));
8875 assert!(!is_lock_marker(vec![0_u8; 19]));
8876
8877 let pending = lock_pending(1_700_000_000).expect("pending");
8878 assert!(is_lock_pending(pending.clone()));
8879 assert!(!is_lock_pending(vec![0_u8; 18]));
8880
8881 assert!(
8882 lock_marker(false, false, 1).is_err(),
8883 "a marker that names no wrap must be rejected"
8884 );
8885 assert!(lock_pending(0).is_err(), "a zero time must be rejected");
8886 }
8887
8888 /// A password wrap is recognizable without running Argon2id.
8889 #[test]
8890 fn lock_password_wrap_is_recognized_without_the_kdf() {
8891 let master = MasterKey::generate().expect("randomness should be available");
8892 let master_bytes = flow::master_key_to_bytes(&master).expose_secret().to_vec();
8893 let file = wrap_master_key_with_password(master_bytes, "correct horse".to_owned())
8894 .expect("wrapping should succeed");
8895
8896 assert!(is_lock_password_wrap(file.clone()));
8897 assert!(!is_lock_password_wrap(file[..file.len() - 1].to_vec()));
8898 assert!(!is_lock_password_wrap(vec![0_u8; 110]));
8899 }
8900
8901 /// Changing the password checks the old one against the open vault's master key.
8902 #[test]
8903 fn rewrap_lock_password_checks_the_old_password() {
8904 let master = MasterKey::generate().expect("randomness should be available");
8905 let master_bytes = flow::master_key_to_bytes(&master).expose_secret().to_vec();
8906 let vault = FfiVault::from_master_key(master_bytes.clone()).expect("the vault should open");
8907 let old_wrap =
8908 wrap_master_key_with_password(master_bytes.clone(), "old password".to_owned())
8909 .expect("the old wrap should be written");
8910
8911 let new_wrap = vault
8912 .rewrap_lock_password(
8913 old_wrap.clone(),
8914 "old password".to_owned(),
8915 "new password".to_owned(),
8916 )
8917 .expect("the new wrap should be written");
8918 assert_eq!(
8919 unwrap_master_key_with_password(new_wrap, "new password".to_owned())
8920 .expect("the new wrap should open"),
8921 master_bytes
8922 );
8923
8924 let error = vault
8925 .rewrap_lock_password(
8926 old_wrap.clone(),
8927 "not the old password".to_owned(),
8928 "new password".to_owned(),
8929 )
8930 .expect_err("a wrong old password must be rejected");
8931 assert_eq!(error.kind(), "lock_password_rejected");
8932
8933 let other_master = MasterKey::generate().expect("randomness should be available");
8934 let other_vault = FfiVault::from_master_key(
8935 flow::master_key_to_bytes(&other_master)
8936 .expose_secret()
8937 .to_vec(),
8938 )
8939 .expect("the other vault should open");
8940 let error = other_vault
8941 .rewrap_lock_password(
8942 old_wrap.clone(),
8943 "old password".to_owned(),
8944 "new password".to_owned(),
8945 )
8946 .expect_err("a wrap from another account must be rejected");
8947 assert_eq!(error.kind(), "lock_password_rejected");
8948
8949 let error = vault
8950 .rewrap_lock_password(old_wrap, "old password".to_owned(), String::new())
8951 .expect_err("an empty new password must be rejected");
8952 assert_eq!(error.kind(), "lock_passphrase_empty");
8953 }
8954
8955 /// A backend with fixed answers, so the wrapper's mapping can be tested without a prompt.
8956 #[derive(Debug)]
8957 struct UnsupportedAuthStore;
8958
8959 impl bioauth::AuthBoundKeyStore for UnsupportedAuthStore {
8960 fn availability(&self) -> bioauth::Availability {
8961 bioauth::Availability::Unsupported {
8962 reason: "test backend",
8963 }
8964 }
8965
8966 fn create(&self) -> bioauth::Result<()> {
8967 Err(bioauth::Error::Unavailable {
8968 reason: "test backend",
8969 })
8970 }
8971
8972 fn wrap(&self, _master: &bioauth::MasterKey) -> bioauth::Result<Vec<u8>> {
8973 Err(bioauth::Error::Unavailable {
8974 reason: "test backend",
8975 })
8976 }
8977
8978 fn unwrap(&self, _blob: &[u8]) -> bioauth::Result<bioauth::MasterKey> {
8979 Err(bioauth::Error::Unavailable {
8980 reason: "test backend",
8981 })
8982 }
8983
8984 fn delete_blob(&self, _blob: &[u8]) -> bioauth::Result<()> {
8985 Ok(())
8986 }
8987
8988 fn delete(&self) -> bioauth::Result<()> {
8989 Ok(())
8990 }
8991 }
8992
8993 /// A host sealer whose answers are fixed, so the boundary can be exercised here.
8994 #[derive(Debug)]
8995 struct FakeHostSealer {
8996 cancelled: std::sync::atomic::AtomicBool,
8997 }
8998
8999 impl FfiPlatformSealer for FakeHostSealer {
9000 fn backend(&self) -> String {
9001 "android-keystore".to_owned()
9002 }
9003
9004 fn availability(&self) -> FfiAuthAvailability {
9005 FfiAuthAvailability::Available {
9006 backend: "android-keystore".to_owned(),
9007 hardware_backed: true,
9008 }
9009 }
9010
9011 fn seal(&self, plaintext: Vec<u8>) -> Result<Vec<u8>, FfiError> {
9012 if self.cancelled.load(std::sync::atomic::Ordering::SeqCst) {
9013 return Err(FfiError::AuthBound {
9014 kind: "bioauth_authentication_failed".to_owned(),
9015 detail: "the prompt was dismissed".to_owned(),
9016 });
9017 }
9018 Ok(plaintext.iter().map(|byte| byte ^ 0x5A).collect())
9019 }
9020
9021 fn unseal(&self, envelope: Vec<u8>) -> Result<Vec<u8>, FfiError> {
9022 if self.cancelled.load(std::sync::atomic::Ordering::SeqCst) {
9023 return Err(FfiError::AuthBound {
9024 kind: "bioauth_authentication_failed".to_owned(),
9025 detail: "the prompt was dismissed".to_owned(),
9026 });
9027 }
9028 Ok(envelope.iter().map(|byte| byte ^ 0x5A).collect())
9029 }
9030
9031 fn delete(&self) -> Result<(), FfiError> {
9032 Ok(())
9033 }
9034 }
9035
9036 /// A host-supplied sealer seals and unseals through the same envelope.
9037 #[test]
9038 fn a_host_sealer_roundtrips() {
9039 let host = Arc::new(FakeHostSealer {
9040 cancelled: std::sync::atomic::AtomicBool::new(false),
9041 });
9042 let store = FfiAuthBoundKeyStore::with_host(host.clone(), "android-keystore".to_owned())
9043 .expect("the backend name is known");
9044 assert!(matches!(
9045 store.availability(),
9046 FfiAuthAvailability::Available { .. }
9047 ));
9048
9049 let master = vec![5_u8; 32];
9050 let blob = store.wrap(master.clone()).expect("seal");
9051 assert_ne!(&blob[blob.len() - 32..], master.as_slice());
9052 assert_eq!(store.unwrap(blob).expect("unseal"), master);
9053
9054 host.cancelled
9055 .store(true, std::sync::atomic::Ordering::SeqCst);
9056 let error = match store.wrap(master.clone()) {
9057 Ok(_) => panic!("a cancelled gesture must fail"),
9058 Err(error) => error,
9059 };
9060 assert_eq!(error.kind(), "bioauth_authentication_failed");
9061 }
9062
9063 /// A backend name this build does not know is rejected at construction.
9064 #[test]
9065 fn an_unknown_host_backend_is_rejected() {
9066 let host = Arc::new(FakeHostSealer {
9067 cancelled: std::sync::atomic::AtomicBool::new(false),
9068 });
9069 let built = FfiAuthBoundKeyStore::with_host(host, "not-a-backend".to_owned());
9070 let error = match built {
9071 Ok(_) => panic!("an unknown backend must be refused"),
9072 Err(error) => error,
9073 };
9074 assert_eq!(error.kind(), "bioauth_unknown_backend");
9075 }
9076
9077 /// The auth-bound wrapper keeps the library's error kinds and never falls back.
9078 #[test]
9079 fn auth_bound_errors_keep_their_kind() {
9080 let store = FfiAuthBoundKeyStore::with_store(Box::new(UnsupportedAuthStore));
9081 assert!(matches!(
9082 store.availability(),
9083 FfiAuthAvailability::Unsupported { .. }
9084 ));
9085
9086 let error = store
9087 .wrap(vec![0_u8; 31])
9088 .expect_err("a 31-byte key must be rejected");
9089 assert_eq!(error.kind(), "bioauth_master_key_wrong_length");
9090
9091 let error = store.create().expect_err("the test backend is unavailable");
9092 assert_eq!(error.kind(), "bioauth_unavailable");
9093
9094 let vault = FfiVault::from_master_key(vec![9_u8; 32]).expect("the vault should open");
9095 let error = vault
9096 .wrap_master_for_auth_bound(store)
9097 .expect_err("the test backend must not wrap");
9098 assert_eq!(error.kind(), "bioauth_unavailable");
9099 }
9100}