Skip to main content

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}