Skip to main content

qualia2fa_core/
error.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//! Error types.
17//!
18//! Every public function of this library returns [`Result`], and **no input may cause a
19//! panic**.
20//!
21//! # Secret-handling discipline
22//!
23//! Error values **never carry secret material**: no raw input, no key bytes, no OTP code. The
24//! variants in this module record only which class of input was invalid plus the **non-secret**
25//! numbers needed to locate the problem (lengths, positions), so a caller can diagnose it
26//! without the value leaking.
27
28use thiserror::Error;
29
30/// The unified error type of this library.
31///
32/// The enum is `#[non_exhaustive]`, so a `match` outside this crate must have a wildcard arm,
33/// and adding a variant is not a breaking change for downstream code. Variants are added as
34/// the library grows.
35#[derive(Debug, Error)]
36#[non_exhaustive]
37pub enum Error {
38    /// A failure reported by the device identity crate.
39    ///
40    /// # Why this wraps the foreign error instead of copying its variants
41    ///
42    /// Device identity (public key representation, fingerprints, signature verification and
43    /// the platform key store) lives in [`qualia_dik`](https://docs.rs/qualia-dik), an
44    /// independently reusable crate. This crate needs **the same** failure classification, not
45    /// a look-alike one of its own.
46    ///
47    /// Copying the variants would let the two enums drift: the other crate gains a
48    /// `HardwareUnavailable`, this one forgets it, and "no TPM" reaches Core callers through a
49    /// silent `_ =>` wildcard arm. Wrapping makes the compiler point out every new variant that
50    /// needs handling when the dependency is upgraded.
51    ///
52    /// The `source()` chain also shows that the failure came from the device identity crate,
53    /// instead of flattening it into a local `reason` string.
54    #[error(transparent)]
55    Device(#[from] qualia_dik::Error),
56
57    /// The Base32 input contains a character outside the alphabet.
58    ///
59    /// Only the **position** is reported, never the character at that position, so that input
60    /// content (which may come from a key) does not reach the logs.
61    #[error("Base32 input has an invalid character at position {index}")]
62    Base32InvalidCharacter {
63        /// Position of the invalid character in the character sequence **after ASCII whitespace
64        /// has been removed**.
65        ///
66        /// It is not a raw byte offset because users often paste seeds from a web page with
67        /// spaces or line breaks, and "which valid character" locates the problem more easily
68        /// than "which byte".
69        index: usize,
70    },
71
72    /// The Base32 input length (after padding is stripped) is not a valid remainder modulo 8.
73    ///
74    /// The only valid Base32 length remainders are 0, 2, 4, 5 and 7.
75    #[error("invalid Base32 length: {length} significant characters")]
76    Base32InvalidLength {
77        /// Number of valid characters after padding is removed.
78        length: usize,
79    },
80
81    /// Corrupt Base32 encoding: the unused bits of the last character are not zero.
82    ///
83    /// This is not a padding-format problem — padding itself may be absent or present (both are
84    /// accepted). It means the encoded data itself is corrupt, for example significant bits
85    /// written into padding bits that must be zero.
86    #[error("Base32 encoding has non-zero residual bits at the end; the data is corrupt")]
87    Base32InvalidPadding,
88
89    /// The OTP secret is empty.
90    ///
91    /// Kept separate from [`Error::SecretTooShort`]: an empty seed is a **structural** problem
92    /// (the URI has no `secret` at all, or Base32 decodes to nothing), while a short seed is a
93    /// **strength** problem. The former must be rejected, the latter is a UI warning — merging
94    /// them leaves the caller unable to tell the two apart.
95    #[error("OTP secret is empty")]
96    SecretEmpty,
97
98    /// The OTP secret is shorter than the minimum required by the selected algorithm.
99    ///
100    /// **Note**: this library currently rejects only empty secrets and never returns this error
101    /// for a short one. HMAC accepts keys of any length and short seeds exist in the real world;
102    /// a short seed is a **warning** to be surfaced by the UI, see
103    /// `otp::recommended_min_secret_len`.
104    #[error("OTP secret is too short: at least {required} bytes required, {actual} bytes given")]
105    SecretTooShort {
106        /// Minimum seed length in bytes required by RFC 6238 for this algorithm.
107        required: usize,
108        /// Actual length in bytes. The length itself is not a secret.
109        actual: usize,
110    },
111
112    /// The `algorithm` parameter of an `otpauth://` URI is not recognized.
113    ///
114    /// **Rejected rather than falling back to SHA-1**: a silent fallback gives the user a code
115    /// that is consistently wrong, with nothing in the error to trace it back.
116    #[error("unsupported algorithm: {name}")]
117    UnsupportedAlgorithm {
118        /// The raw parameter value, truncated to at most 16 characters.
119        ///
120        /// Truncation is required: the value comes from an attacker-controlled URI, and
121        /// arbitrary-length input must not be carried into the logs verbatim.
122        name: String,
123    },
124
125    /// The `digits` parameter of an `otpauth://` URI is outside the supported range.
126    ///
127    /// Reported when the value is not a plain decimal digit count in the first place
128    /// (`""`, `"six"`, `"06"`, a number too large for a byte). A well-formed number that is
129    /// merely out of range is reported by [`Error::DigitsZero`] or [`Error::DigitsTooMany`],
130    /// which carry the reason.
131    #[error("unsupported digit count: {value} (a plain decimal count from 1 to 9 is expected)")]
132    UnsupportedDigits {
133        /// The raw parameter value, truncated to at most 16 characters.
134        value: String,
135    },
136
137    /// A code was requested with zero digits.
138    ///
139    /// A zero-width code is not a code: there would be nothing to display and nothing to
140    /// compare, and `10^0 = 1` makes every code identical.
141    #[error("a code must have at least one digit")]
142    DigitsZero,
143
144    /// A code was requested with ten or more digits.
145    ///
146    /// **The limit comes from the truncation, not from the integer width**: RFC 4226 §5.3
147    /// extracts a 31-bit value from the HMAC, so at ten digits the reachable code space is
148    /// only `2^31 / 10^10` (about 21%) of the range — codes above `2^31` can never appear,
149    /// and the ones that can are not uniformly distributed. Widening the integer type does
150    /// not help; supporting ten digits would need a different truncation, which no existing
151    /// implementation would agree with.
152    #[error(
153        "a code cannot have {count} digits: RFC 4226 §5.3 truncates the HMAC to 31 bits, so ten or more digits would be biased (1 to 9 are accepted)"
154    )]
155    DigitsTooMany {
156        /// The requested digit count.
157        count: u8,
158    },
159
160    /// The TOTP `period` is 0, which would cause a division by zero.
161    #[error("TOTP period must not be 0")]
162    InvalidPeriod,
163
164    /// A code was requested from an algorithm that mixes a user-entered PIN into the
165    /// computation, but no PIN is set.
166    ///
167    /// mOTP and Yandex OTP both fold a PIN into the code. Without one the algorithm has **no
168    /// defined output**: the reference implementation raises an error rather than substituting
169    /// an empty string. This is deliberately an error and not a default — a code computed with
170    /// an empty PIN looks perfectly plausible and no server will ever accept it, which is the
171    /// hardest kind of failure for a user to diagnose.
172    ///
173    /// An **empty** PIN is a different thing and is accepted: it means the user's PIN *is* the
174    /// empty string, which the algorithm handles like any other value.
175    #[error("{otp_type} requires a PIN before a code can be generated")]
176    PinRequired {
177        /// Which algorithm needs it (`"mOTP"` or `"Yandex OTP"`).
178        otp_type: &'static str,
179    },
180
181    /// A Yandex OTP secret is neither 16 nor 26 bytes long.
182    ///
183    /// The scheme has exactly two secret forms: 16 bytes (what a QR code carries) and 26 bytes
184    /// (16 bytes plus a trailing checksum). Any other length is not a Yandex secret, so it is
185    /// rejected instead of being truncated to something plausible.
186    #[error("Yandex OTP secret must be 16 or 26 bytes, got {len}")]
187    YandexSecretLength {
188        /// Actual length in bytes. The length itself is not a secret.
189        len: usize,
190    },
191
192    /// The trailing checksum of a 26-byte Yandex OTP secret does not match its contents.
193    ///
194    /// The checksum is a polynomial remainder over the secret (see `yaotp::normalize_secret`),
195    /// and it is a **data-integrity** check, not a cryptographic one: it exists so that a
196    /// mistyped or truncated secret is reported as damaged at import time instead of turning
197    /// into codes that no server will ever accept. It carries no information about the secret
198    /// itself.
199    #[error("Yandex OTP secret checksum does not match its contents")]
200    YandexSecretChecksum,
201
202    /// Cryptographic random bytes cannot be obtained from the operating system.
203    ///
204    /// This is an **unrecoverable** failure: the library deliberately uses no user-space PRNG as
205    /// a fallback, because a deterministic seed makes independently generated keys collide.
206    /// Failing the operation and letting the user retry is preferable to silently switching to a
207    /// weak random source.
208    ///
209    /// Triggered when the `getrandom` backend is unavailable on the target platform (a
210    /// restricted sandbox, a missing syscall, or a kernel entropy source that failed to
211    /// initialize).
212    ///
213    /// **It carries no `getrandom::Error` detail**: that type's internal representation differs
214    /// across versions and platforms and offers the caller nothing actionable — retrying or
215    /// telling the user is all that can be done.
216    #[error("cannot obtain secure random bytes from the operating system")]
217    RandomnessUnavailable,
218
219    /// The URI scheme is not one this library understands.
220    #[error("unsupported URI scheme (expected otpauth:// or motp:)")]
221    UriUnsupportedScheme,
222
223    /// The URI has no type component (no `/` after the scheme).
224    #[error("URI is missing its type component (expected totp, hotp, motp, yaotp or steam)")]
225    UriMissingType,
226
227    /// The URI type is not one of the supported schemes.
228    #[error("unsupported URI type: {name}")]
229    UriUnsupportedType {
230        /// The raw type component, truncated to at most 16 characters.
231        name: String,
232    },
233
234    /// A parameter that has no meaning for this type is present.
235    ///
236    /// Rejected rather than ignored, for the reason [`Error::UriUnexpectedCounter`] gives: a
237    /// parameter that is silently dropped is a configuration the user believes in and that the
238    /// code does not implement.
239    #[error("a {uri_type} URI cannot carry {parameter}")]
240    UriParameterNotSupported {
241        /// The parameter name, from this library's own list.
242        parameter: &'static str,
243        /// The URI type, from this library's own list.
244        uri_type: &'static str,
245    },
246
247    /// A `yaotp` URI asked for an algorithm other than SHA-256.
248    ///
249    /// Yandex OTP truncates an **eight-byte** window of the digest, so a shorter digest (SHA-1
250    /// has 20 bytes and the window starts as late as byte 15) would read past its end. The
251    /// scheme is defined for SHA-256 only; honouring the parameter is impossible and ignoring
252    /// it would compute codes that never match.
253    #[error(
254        "Yandex OTP is defined for SHA-256 only, but the URI asked for {algorithm}: the eight-byte truncation reads past the end of a shorter digest"
255    )]
256    YandexAlgorithmUnsupported {
257        /// The algorithm name the URI carried.
258        algorithm: &'static str,
259    },
260
261    /// A `steam` URI asked for an algorithm other than SHA-1.
262    #[error("Steam Guard is defined for SHA-1 only, but the URI asked for {algorithm}")]
263    SteamAlgorithmUnsupported {
264        /// The algorithm name the URI carried.
265        algorithm: &'static str,
266    },
267
268    /// A `steam` URI asked for a code length other than five.
269    #[error("a Steam Guard code is always 5 characters, but the URI asked for {digits}")]
270    SteamDigitsUnsupported {
271        /// The digit count the URI carried.
272        digits: u8,
273    },
274
275    /// A `pin` parameter is not usable.
276    ///
277    /// The pin travels Base32-encoded (so that any character a user may choose survives the
278    /// query string), and the decoded bytes must be UTF-8.
279    #[error("the pin parameter is invalid: {reason}")]
280    UriInvalidPin {
281        /// What was wrong, without echoing the pin itself.
282        reason: &'static str,
283    },
284
285    /// The URI has no `secret` parameter.
286    #[error("URI is missing the required secret parameter")]
287    UriMissingSecret,
288
289    /// The HOTP URI has no `counter` parameter.
290    #[error("HOTP URI is missing the required counter parameter")]
291    UriMissingCounter,
292
293    /// A TOTP URI carries a `counter` parameter.
294    ///
295    /// Rejected rather than ignored: such a URI signals a faulty generator, and ignoring it
296    /// hands the user an entry that looks normal but means something else.
297    #[error("TOTP URI must not carry a counter parameter")]
298    UriUnexpectedCounter,
299
300    /// The URI contains a known parameter more than once.
301    ///
302    /// Implementations disagree on whether the first or the last occurrence wins: a classic
303    /// parsing ambiguity, and an attack surface.
304    #[error("URI contains a duplicate parameter: {name}")]
305    UriDuplicateParameter {
306        /// The duplicated parameter name (truncated to 16 characters).
307        name: String,
308    },
309
310    /// A URI parameter value is invalid.
311    #[error("invalid URI parameter {name}: {reason}")]
312    UriInvalidParameter {
313        /// Parameter name.
314        name: String,
315        /// Reason that does not include the full raw value (the value itself is truncated).
316        reason: String,
317    },
318
319    /// The percent encoding in the URI is malformed.
320    #[error("URI contains invalid percent encoding (% must be followed by two hexadecimal digits)")]
321    UriInvalidPercentEncoding,
322
323    /// The issuer in the label and the `issuer` parameter disagree.
324    ///
325    /// Rejected: two different issuers mean the URI was assembled or tampered with, and silently
326    /// picking one would bind an entry's codes to the wrong service.
327    #[error("issuer conflict: the label says {in_label}, the parameter says {in_parameter}")]
328    UriIssuerMismatch {
329        /// The issuer from the label prefix (truncated).
330        in_label: String,
331        /// The value from the `issuer` parameter (truncated).
332        in_parameter: String,
333    },
334
335    /// The account name in the URI label contains `:`.
336    ///
337    /// The label uses `:` to separate issuer from account, so a `:` inside the account name
338    /// cannot be told apart from the separator and creates round-trip ambiguity: after a
339    /// serialize-then-parse cycle, the ownership of issuer and account changes **silently**.
340    /// Percent encoding does not help — `%3A` and a literal `:` are indistinguishable once
341    /// decoded.
342    ///
343    /// Both parsing and serialization therefore reject it, enforcing the invariant.
344    #[error(
345        "the account name must not contain a colon (it is confused with the issuer separator): {account}"
346    )]
347    UriAccountContainsColon {
348        /// The account name, truncated to 32 characters.
349        account: String,
350    },
351
352    /// The vault file is shorter than its declared structure, or the structure is cut off here.
353    ///
354    /// The most common structural problem: the file was truncated, or a length field claims more
355    /// bytes than actually remain.
356    #[error("vault file is incomplete or truncated")]
357    VaultTruncated,
358
359    /// The magic number at the start of the vault file does not match.
360    ///
361    /// It means this is not a vault file (the wrong file was picked, or the file was replaced
362    /// with something else).
363    #[error("not a Qualia2FA vault file")]
364    VaultBadMagic,
365
366    /// The vault file format version is not supported by this build.
367    #[error("unsupported vault format version: {version}")]
368    VaultUnsupportedVersion {
369        /// Version declared in the file.
370        version: u8,
371    },
372
373    /// The reserved byte in the vault file header is not 0.
374    ///
375    /// Reserved bytes exist precisely so that they may become non-zero later. The current
376    /// version must reject a non-zero value: it means the file was written by a newer version
377    /// that may have given the byte a meaning this version does not know, and reading on would
378    /// parse it under wrong assumptions.
379    #[error("vault reserved byte must be 0, but is {value}")]
380    VaultReservedByteNotZero {
381        /// Actual value in the file.
382        value: u8,
383    },
384
385    /// The record kind byte in the vault file is not recognized.
386    ///
387    /// **Not treated as a live entry**: guessing that an unknown kind "is probably an entry"
388    /// would let a tombstone written by a future version come back to life as an entry here.
389    #[error("unrecognized vault record kind: {value}")]
390    VaultUnknownRecordKind {
391        /// Actual value in the file.
392        value: u8,
393    },
394
395    /// A single record exceeds the length limit.
396    ///
397    /// The limit exists to reject a malicious file claiming to hold 4 GiB and to avoid allocating
398    /// huge buffers for untrusted input. This is therefore a **structural** problem, not the
399    /// business problem of "an entry that is too large".
400    #[error("vault record is too long: limit {limit} bytes, {actual} bytes declared")]
401    VaultRecordTooLong {
402        /// Allowed limit.
403        limit: usize,
404        /// Length declared in the file.
405        actual: usize,
406    },
407
408    /// The vault holds more records than the limit allows.
409    #[error(
410        "vault record count exceeds the limit (at most {})",
411        crate::vault::MAX_RECORDS
412    )]
413    VaultTooManyRecords,
414
415    /// The vault file has extra bytes at the end.
416    ///
417    /// **Rejected rather than ignored**: extra bytes mean content was appended and this version
418    /// cannot explain what it is. Ignoring them would make the fact that the file was modified
419    /// disappear.
420    #[error("vault file has {count} extra bytes at the end")]
421    VaultTrailingBytes {
422        /// Number of extra bytes.
423        count: usize,
424    },
425
426    /// The same `entry_id` appears twice in the vault file.
427    ///
428    /// **Rejected rather than overwritten**: a duplicate ID means the file was crafted or
429    /// corrupted. Silently keeping one of them makes the user lose a 2FA entry, showing up as
430    /// "one code suddenly stopped working" — extremely hard to trace.
431    #[error("vault file contains a duplicate entry ID")]
432    VaultDuplicateEntryId,
433
434    /// The master key check value in the file has the wrong structure (length mismatch).
435    ///
436    /// Strictly separated from [`Error::VaultDecryptionFailed`]: **this one is a structural
437    /// problem, that one means a wrong key or a modified file**. Merging the two reports a
438    /// crafted file as "your key is wrong", sending the user to compare keys when the problem is
439    /// somewhere else entirely.
440    ///
441    /// The check value is a fixed-length plaintext field, so a length mismatch means the file was
442    /// truncated, appended to, or never written in the format at all — all three mean "this file
443    /// is untrustworthy", not "wrong key".
444    #[error("vault file has a master key check value of the wrong length")]
445    VaultBadKeyCheck,
446
447    /// No entry with this identifier exists.
448    ///
449    /// Different from "the vault is empty": the caller named one specific entry and it is not
450    /// there. The layer above must tell two UI actions apart — "the list is empty, invite the
451    /// user to add an entry" versus "the entry you were working on is gone, refresh the list".
452    /// Treating the latter as the former shows an empty list while the user was just looking at
453    /// the entry.
454    ///
455    /// A common cause is synchronization having deleted the entry on the other device while this
456    /// one still holds its identifier.
457    #[error("no such entry in the vault")]
458    VaultEntryNotFound,
459
460    /// A vault file holds two group records with the same identifier.
461    ///
462    /// Refused rather than treated as an overwrite, for the same reason as
463    /// [`Error::VaultDuplicateEntryId`]: one of the two orderings would be dropped silently, and
464    /// an ordering that disappears takes the user's arrangement with it.
465    #[error("the vault holds two groups with the same identifier")]
466    VaultDuplicateGroupId,
467
468    /// No group with that identifier exists.
469    #[error("no such group in the vault")]
470    VaultGroupNotFound,
471
472    /// An ordering names an entry the vault does not hold.
473    ///
474    /// Rejected when **setting** an ordering: the caller passes identifiers, and one that names
475    /// nothing means the caller is out of step with the vault. When **reading** a file, a
476    /// reference to a missing entry is skipped instead - there the caller is a peer device and
477    /// the entries may simply not have arrived yet.
478    #[error("an ordering names an entry the vault does not hold")]
479    VaultGroupUnknownEntry,
480
481    /// The top group cannot be renamed, removed or reordered.
482    ///
483    /// It is the collection of the entries that belong to no named group: it is pinned at the
484    /// top of the interface and has no name to edit, so every operation that would change what
485    /// it *is* is refused rather than silently ignored. Its contents (the ordering) remain
486    /// editable, which is the whole point of it being a group.
487    #[error("the top group cannot be renamed, removed or reordered")]
488    VaultTopGroupImmutable,
489
490    /// One group holds more child references than the format can carry.
491    ///
492    /// The list travels in a single length-prefixed node, so it is bounded by
493    /// [`Error::FieldTooLong`]'s limit divided by the size of one child reference.
494    #[error("a group holds {actual} children, at most {limit} fit in the format")]
495    VaultGroupTooManyChildren {
496        /// The maximum the format allows.
497        limit: usize,
498        /// How many were supplied.
499        actual: usize,
500    },
501
502    /// One child holds two positions in the same ordering.
503    ///
504    /// A list is a **sequence**: the same entry or group cannot come both before and after
505    /// another one. Two different groups holding the same child is a different question, and
506    /// the vault answers it by moving the child instead of refusing.
507    #[error("a child appears twice in the same ordering")]
508    VaultGroupDuplicateChild,
509
510    /// Moving the children would put a group inside itself.
511    ///
512    /// The tree has no cycles by definition: "the parent of A is B" and "the parent of B is A"
513    /// cannot both hold. Offering such a list is refused before anything is written, rather
514    /// than left for a traversal to discover - a cycle makes every walk over the tree either
515    /// loop forever or need a visited set, and the one place to prevent it is where the list
516    /// is set.
517    #[error("the children would put a group inside itself")]
518    VaultGroupCycle,
519
520    /// A group would be nested more deeply than the tree allows.
521    ///
522    /// The top group is depth zero. Past the limit the interface has no room to indent a group,
523    /// so the nesting is refused when a list is written and repaired when a file is read.
524    #[error("a group would be nested {actual} levels deep, at most {limit} are allowed")]
525    VaultGroupDepthExceeded {
526        /// The deepest nesting this library allows.
527        limit: usize,
528        /// How deep the operation would place the group.
529        actual: usize,
530    },
531
532    /// The export is larger than this library reads.
533    ///
534    /// Compared **before** anything is allocated or parsed: the size comes from the file, and a
535    /// small header claiming gigabytes must not turn into an allocation.
536    #[error("the export is {actual} bytes; at most {limit} are read")]
537    ImportFileTooLarge {
538        /// The limit.
539        limit: usize,
540        /// The size of the input.
541        actual: usize,
542    },
543
544    /// The export holds more entries than this library reads.
545    #[error("the export holds {actual} entries; at most {limit} are read")]
546    ImportTooManyEntries {
547        /// The limit.
548        limit: usize,
549        /// How many the export holds.
550        actual: usize,
551    },
552
553    /// The export nests more deeply than this library reads.
554    ///
555    /// Checked by scanning the bytes **before** they reach the JSON parser: that parser recurses,
556    /// so a deeply nested document would exhaust the stack, and a stack overflow is not a
557    /// catchable error - it takes the process down, which is exactly what "no input may cause a
558    /// panic" forbids.
559    #[error("the export nests more than {limit} levels deep")]
560    ImportTooDeeplyNested {
561        /// The limit.
562        limit: usize,
563    },
564
565    /// The export is structurally wrong: not the expected format, a missing field, a value of the
566    /// wrong kind.
567    #[error("the export is malformed: {reason}")]
568    ImportMalformed {
569        /// What is wrong, in one phrase. Never carries export content.
570        reason: &'static str,
571    },
572
573    /// The export declares a version this library does not read.
574    #[error("the export declares version {version}; at most {supported} is read")]
575    ImportUnsupportedVersion {
576        /// The version in the file.
577        version: u64,
578        /// The highest version this library reads.
579        supported: u64,
580    },
581
582    /// The export is encrypted and this call carried no password.
583    #[error("the export is encrypted; a password is required")]
584    ImportPasswordRequired,
585
586    /// The export is encrypted in a way this reader cannot open.
587    ///
588    /// Distinct from [`Error::ImportPasswordRequired`], which means "give me a password": here a
589    /// password was given and there is no slot it could open - the export is locked to the
590    /// platform key store or to a biometric prompt of the device that wrote it.
591    #[error("the export is encrypted with a method this reader cannot use: {reason}")]
592    ImportEncryptionUnsupported {
593        /// What is missing, in one phrase.
594        reason: &'static str,
595    },
596
597    /// The password does not open the encrypted export.
598    ///
599    /// **One error for every failing slot**: whether the password is wrong or a slot was tampered
600    /// with is not something the reader can tell apart, and a message that guessed would be wrong
601    /// half the time.
602    #[error("the password does not open this export")]
603    ImportWrongPassword,
604
605    /// The export decrypted but its contents do not open.
606    ///
607    /// Distinct from [`Error::ImportWrongPassword`] on purpose, because it means something else
608    /// to the user: the password was accepted (a slot authenticated), so the **database** is
609    /// damaged or was modified after it was written.
610    #[error(
611        "the export was decrypted but its contents do not open: the file is damaged or was modified"
612    )]
613    ImportCorrupted,
614
615    /// The key derivation parameters of an encrypted export are outside the accepted range.
616    ///
617    /// Refused **before** the derivation runs: those numbers come from the file, they decide how
618    /// much memory and time the derivation costs, and the reference implementation does not bound
619    /// them.
620    #[error("the export's key derivation parameters are out of range: {reason}")]
621    ImportInvalidKdfParameters {
622        /// Which parameter, and why it is refused.
623        reason: &'static str,
624    },
625
626    /// An encrypted export was asked for without a password.
627    ///
628    /// The caller decides this before calling [`crate::export::export`] with
629    /// [`crate::export::ExportFormat::needs_password`]; reaching this error means the decision
630    /// was made wrongly, and the alternative - writing an unencrypted file because no password
631    /// arrived - would be a silent downgrade of something the user asked to protect.
632    #[error("an encrypted export needs a password")]
633    ExportPasswordRequired,
634
635    /// The password given for an encrypted export is empty.
636    ///
637    /// Refused rather than accepted: an empty password is one anybody can guess by trying
638    /// nothing at all, and the file holds every secret in the vault.
639    #[error("an encrypted export needs a password that is not empty")]
640    ExportPasswordEmpty,
641
642    /// An entry's URI does not fit a QR code, so the HTML export cannot draw it.
643    ///
644    /// Reported with the entry's **position** and nothing else: an error message ends up in a
645    /// log, and the entry's name and issuer are the user's own account data.
646    #[error("the URI of the entry at position {position} does not fit a QR code")]
647    ExportQrTooLong {
648        /// Where in the export the entry was, counting from 1.
649        position: usize,
650    },
651
652    /// The vault is larger than the container of an encrypted export.
653    ///
654    /// AES-256-GCM caps a message at 2^36 - 32 bytes, and the whole database goes into one
655    /// message. A vault would have to hold tens of gigabytes of entries to reach it.
656    #[error("the vault is larger than one encrypted message can hold")]
657    ExportTooLarge,
658
659    /// A fixed parameter of an export format was refused by the code that implements it.
660    ///
661    /// Unreachable in practice: the parameters are constants of the format, and a test pins that
662    /// the implementations accept them. It is an error rather than a panic because this library
663    /// does not panic, and rather than an `unwrap` because an unwrap in a library is a crash
664    /// waiting for the one build where the constant was edited.
665    #[error("the export could not be written: a fixed parameter was refused ({parameter})")]
666    ExportParameterRefused {
667        /// Which parameter, in one phrase.
668        parameter: &'static str,
669    },
670
671    /// A portable vault file (`.q2fa`) does not begin with a magic this library knows.
672    ///
673    /// The three shapes are the plain vault container, the passphrase-protected one, and the
674    /// old key-only recovery file. A caller that expected a backup and got something else is
675    /// told so before any password is asked for.
676    #[error("the file is not a portable vault this library writes")]
677    PortableBadMagic,
678
679    /// A portable vault file declares a container version this library does not read.
680    #[error("the portable vault declares version {version}, which this build does not read")]
681    PortableUnsupportedVersion {
682        /// The version in the file.
683        version: u8,
684    },
685
686    /// A portable vault file ends before the length its header declares.
687    ///
688    /// Checked before the payload is allocated: the length comes from the file and is
689    /// attacker-controlled.
690    #[error("the portable vault is truncated")]
691    PortableTruncated,
692
693    /// A portable vault file is longer or shorter than its lengths add up to.
694    ///
695    /// Refused rather than ignored: trailing bytes mean the file was appended to or assembled
696    /// from parts, and silently reading the first part would hide that.
697    #[error("the portable vault holds content beyond the lengths its header declares")]
698    PortableLengthMismatch,
699
700    /// A portable vault payload is larger than this library accepts.
701    #[error("the portable vault payload is {actual} bytes; at most {limit} are read")]
702    PortableTooLarge {
703        /// The limit.
704        limit: usize,
705        /// The size the header declares.
706        actual: usize,
707    },
708
709    /// The file is a bare vault file, which carries ciphertext but **no master key**.
710    ///
711    /// It looks like a backup and is not one: importing it would replace the local vault with
712    /// ciphertext the local key probably cannot open. The caller is told so it can say what to
713    /// do (export a vault backup instead), rather than leaving the user with a vault that
714    /// cannot be read.
715    #[error("this is a bare vault file and carries no master key")]
716    PortableVaultFileWithoutKey,
717
718    /// A passphrase-protected portable vault was opened without a password.
719    #[error("this portable vault is protected by a passphrase")]
720    PortablePasswordRequired,
721
722    /// The plaintext structure of a vault entry contains a duplicated field node.
723    ///
724    /// Implementations disagree on whether the first or the last occurrence wins: a classic
725    /// parsing ambiguity, and an attack surface — an attacker can append an overriding node after
726    /// a legitimate one.
727    #[error("vault entry contains a duplicate field (tag {tag:#04x})")]
728    VaultDuplicateField {
729        /// The field tag that appears more than once.
730        tag: u8,
731    },
732
733    /// The plaintext structure of a vault entry is corrupt (missing required field, wrong field
734    /// length, and so on).
735    ///
736    /// Note that this error can only occur **after authentication has succeeded** — the AEAD tag
737    /// covers the whole plaintext, so successful decryption means the plaintext is the one that
738    /// was written. Reaching it means the writing side produced an invalid structure (a bug in
739    /// this library), or the key comes from an implementation of another version. **A parser
740    /// called directly with arbitrary plaintext can trigger it as well**, since such a call
741    /// bypasses the AEAD.
742    #[error("vault entry structure is corrupt")]
743    VaultCorrupt,
744
745    /// A text field of a vault entry is not valid UTF-8.
746    ///
747    /// **Rejected rather than lossily replaced**: replacement turns corrupt data into an entry
748    /// that looks normal but has different content, and the user never learns that the account
749    /// name was changed.
750    #[error("a text field of the vault entry is not valid UTF-8")]
751    VaultInvalidUtf8,
752
753    /// Vault decryption or authentication failed.
754    ///
755    /// **It does not distinguish** a wrong master key, tampered ciphertext and an AAD mismatch,
756    /// for two reasons:
757    ///
758    /// - **Useful to an attacker**: telling them apart is an oracle that reveals whether a
759    ///   guessed key is correct, or which part was modified.
760    /// - **Useless to the user**: all three call for exactly the same action — this vault cannot
761    ///   be opened.
762    ///
763    /// Whether the wrong master key was picked is something the layer above decides from where
764    /// the key came from; the cryptographic layer need not leak anything for that.
765    #[error("cannot decrypt the vault: master key mismatch or the file has been tampered with")]
766    VaultDecryptionFailed,
767
768    /// The master key has the wrong length.
769    #[error("master key must be {expected} bytes, but is {actual} bytes")]
770    MasterKeyWrongLength {
771        /// Required length.
772        expected: usize,
773        /// Actual length.
774        actual: usize,
775    },
776
777    /// A variable-length field of a vault entry exceeds the limit.
778    ///
779    /// The limit cannot be enforced **before** decryption from the ciphertext length (that length
780    /// says nothing about the field length), so it must be checked while parsing after
781    /// decryption. See [`crate::vault::Entry::MAX_FIELD_LEN`].
782    #[error("field {field} is too long: limit {limit} bytes, {actual} bytes given")]
783    FieldTooLong {
784        /// Field name (a fixed string, never user data).
785        field: &'static str,
786        /// Allowed limit.
787        limit: usize,
788        /// Actual length.
789        actual: usize,
790    },
791
792    /// Reading or writing a vault file failed.
793    ///
794    /// It carries only the **operating system error description**, never file content — `reason`
795    /// comes from `std::io::Error`, which does not include the bytes that were written.
796    #[error("vault file read/write failed: {reason}")]
797    VaultIo {
798        /// Operating system error description.
799        reason: String,
800    },
801
802    /// A device identity public key is invalid.
803    ///
804    /// Public keys come from external input (QR codes, the network) and must be validated before
805    /// use. **Invalid-curve attacks** are real: handing over a point that is not on the curve but
806    /// lies in a small subgroup can leak private-key information through the peer's computation.
807    /// This error is therefore part of the security boundary, not a format check.
808    ///
809    /// `reason` is a fixed internal description and **contains no raw input bytes** — they may
810    /// come from an attacker, and putting them into an error message turns logs into an injection
811    /// point.
812    #[error("invalid device public key: {reason}")]
813    DevicePublicKeyInvalid {
814        /// Fixed reason description.
815        reason: &'static str,
816    },
817
818    /// A device identity signature is malformed or fails verification.
819    ///
820    /// **A malformed signature and a failed verification deliberately share this variant.** The
821    /// caller's response is identical (reject this authentication), and telling them apart would
822    /// only give an attacker a small oracle.
823    #[error("invalid device signature: {reason}")]
824    DeviceSignatureInvalid {
825        /// Fixed reason description.
826        reason: &'static str,
827    },
828
829    /// The pairing payload (QR code content) is structurally invalid.
830    ///
831    /// Separate from [`Error::PairingUnsupportedVersion`]: a wrong length or a missing required
832    /// field is damage **this version can judge**, whereas an unrecognized version is something
833    /// this version **cannot read**. For the user the advice differs — "regenerate the QR code"
834    /// versus "upgrade the app" — so the two are not merged.
835    ///
836    /// `reason` is a fixed internal description and **contains no input bytes**: QR code content
837    /// may come from an attacker.
838    #[error("invalid pairing payload: {reason}")]
839    PairingPayloadMalformed {
840        /// Fixed reason description.
841        reason: &'static str,
842    },
843
844    /// The pairing payload protocol version is not supported.
845    ///
846    /// It must be **rejected outright** rather than interpreted as best as possible: reading a
847    /// future version's fields under this version's layout would turn a "this operation is
848    /// forbidden" written by the future version into "allowed".
849    #[error("unsupported pairing protocol version: {version}")]
850    PairingUnsupportedVersion {
851        /// Version declared in the payload.
852        version: u8,
853    },
854
855    /// Pairing message authentication failed.
856    ///
857    /// **Tampered ciphertext, a wrong tag, a message from another session and a joiner using the
858    /// wrong public key** deliberately share this variant. The caller's response is identical
859    /// (this pairing failed, scan the code again), and telling them apart would give an attacker
860    /// an oracle to probe with.
861    #[error(
862        "pairing message authentication failed: tampered ciphertext, or not part of this pairing"
863    )]
864    PairingAuthenticationFailed,
865
866    /// The recovery file structure is invalid.
867    ///
868    /// Separate from [`Error::RecoveryAuthenticationFailed`] because the two mean completely
869    /// different things to the **user**: this variant says "wrong file / broken file", while that
870    /// one says "wrong passphrase". Merging them makes the user retry the passphrase in a
871    /// direction where retrying cannot help.
872    ///
873    /// `reason` is a fixed internal description and **contains no input bytes**.
874    #[error("invalid recovery file: {reason}")]
875    RecoveryFileMalformed {
876        /// Fixed reason description.
877        reason: &'static str,
878    },
879
880    /// The derivation parameters of the recovery file are invalid.
881    ///
882    /// These parameters **come from the file itself** and are therefore untrusted input. Without
883    /// this validation, a file edited to say `m_cost = u32::MAX` would turn an import into a
884    /// memory-exhaustion attack.
885    #[error("invalid recovery file derivation parameters: {reason}")]
886    RecoveryParametersInvalid {
887        /// Fixed reason description.
888        reason: &'static str,
889    },
890
891    /// The passphrase is empty.
892    ///
893    /// **Rejected outright rather than accepted as a weak passphrase**: accepting it would hand
894    /// the user, behind a screen that looks successful, a backup that can be opened in about a
895    /// second.
896    #[error("recovery passphrase must not be empty")]
897    RecoveryPassphraseEmpty,
898
899    /// Authentication of the recovery file failed: wrong passphrase, or a tampered file.
900    ///
901    /// The two causes deliberately share this variant (same reasoning as
902    /// [`Error::PairingAuthenticationFailed`]).
903    #[error(
904        "recovery file authentication failed: wrong passphrase, or the file has been tampered with"
905    )]
906    RecoveryAuthenticationFailed,
907
908    /// The encryption step of the recovery file failed.
909    ///
910    /// It can only come from an AEAD implementation limit (plaintext or AAD too long) and does
911    /// not happen with the parameters in use; it is listed separately so that this case is not
912    /// misreported as a wrong passphrase.
913    #[error("recovery file encryption failed")]
914    RecoveryEncryptionFailed,
915
916    /// The Argon2id computation failed.
917    ///
918    /// The most common real cause is **out of memory**: the default parameters allocate 64 MiB
919    /// at once.
920    #[error("passphrase derivation failed (possibly out of memory)")]
921    RecoveryKdfFailed,
922
923    // ── Application lock (`crate::lock`) ─────────────────────────────────────
924    /// The password-wrapped master key file is structurally invalid.
925    ///
926    /// Separate from [`Error::LockPasswordRejected`]: this variant means the bytes are not a
927    /// lock wrap at all (wrong length or magic), while that one means the file looked right but
928    /// the password did not open it. Merging them would make the user retry the password when
929    /// retrying cannot help.
930    ///
931    /// `reason` is a fixed internal description and **contains no input bytes**.
932    #[error("invalid password-wrapped master key: {reason}")]
933    LockFileMalformed {
934        /// Fixed reason description.
935        reason: &'static str,
936    },
937
938    /// The derivation parameters of a password-wrapped master key are invalid.
939    ///
940    /// These parameters come from the file and are therefore untrusted input. They are checked
941    /// **before** Argon2id runs, so a crafted file cannot make the application allocate several
942    /// terabytes or derive forever.
943    #[error("invalid lock wrap derivation parameters: {reason}")]
944    LockParametersInvalid {
945        /// Fixed reason description.
946        reason: &'static str,
947    },
948
949    /// The unlock password is empty.
950    ///
951    /// An empty password is rejected outright rather than accepted as a weak one: accepting it
952    /// would present an unlock screen that anybody can pass in a second.
953    #[error("unlock password must not be empty")]
954    LockPassphraseEmpty,
955
956    /// The password did not open the wrapped master key.
957    ///
958    /// A wrong password and a tampered file deliberately share this variant: telling them apart
959    /// would give an attacker an oracle for whether a guess was right, and the user's response
960    /// is the same in both cases.
961    #[error("the password did not open the wrapped master key")]
962    LockPasswordRejected,
963
964    /// The encryption step of the password wrap failed.
965    ///
966    /// It can only come from an AEAD implementation limit and does not happen with the lengths
967    /// in use; it is listed separately so that this case is not misreported as a wrong password.
968    #[error("lock wrap encryption failed")]
969    LockEncryptionFailed,
970
971    /// The Argon2id computation behind a password wrap failed.
972    ///
973    /// The most common real cause is **out of memory**: the default parameters allocate 64 MiB
974    /// at once.
975    #[error("lock wrap passphrase derivation failed (possibly out of memory)")]
976    LockKdfFailed,
977
978    /// The version of a persisted format is not supported.
979    ///
980    /// Used for data that is **kept for a long time**, such as recovery files: when a file written
981    /// by a future version reaches an older one, it must be **rejected outright** instead of being
982    /// interpreted under the current layout. The latter would turn "the new version improved
983    /// security" into "the older version accepted it under a weaker interpretation".
984    #[error("unsupported format version: {version}")]
985    UnsupportedFormatVersion {
986        /// Version declared in the file.
987        version: u8,
988    },
989
990    /// A challenge-response message is structurally invalid.
991    ///
992    /// A wrong length, or a carried public key that is not a valid curve point. **This is a
993    /// different thing from [`Error::AuthProofFailed`]**: this variant says the message is not
994    /// even well-formed, that one says the message is well-formed but the peer cannot compute the
995    /// correct tag.
996    ///
997    /// `reason` is a fixed internal description and **contains no input bytes**.
998    #[error("invalid authentication message: {reason}")]
999    AuthMessageMalformed {
1000        /// Fixed reason description.
1001        reason: &'static str,
1002    },
1003
1004    /// The proof or confirmation tag of the challenge-response does not match.
1005    ///
1006    /// It means "the peer failed to prove that it holds the master key". A tampered tag, an
1007    /// inconsistent session identifier, or a peer that has no master key at all — the three causes
1008    /// deliberately share this variant, because the caller's response is identical (declare this
1009    /// authentication failed and disconnect) and telling them apart would give a probeable signal.
1010    #[error("authentication failed: the peer did not prove possession of the master key")]
1011    AuthProofFailed,
1012
1013    // ── Orchestration (`crate::flow`) ─────────────────────────────────────────
1014    /// This device has no device identity yet, and this step needs one.
1015    ///
1016    /// Different from "creating the identity failed": this is a caller asking for a signature
1017    /// before obtaining an identity — a call-order error, not a platform capability problem.
1018    #[error("this device has no device identity yet; obtain one before signing")]
1019    DeviceKeyMissing,
1020
1021    /// This device has never registered or logged in.
1022    ///
1023    /// Deliberately separate from "a master key exists but cannot be read": the former is the
1024    /// normal path (send the user to register or log in), the latter means data may already be
1025    /// damaged. Sharing one error would make the UI treat the latter as "not logged in yet" and
1026    /// send the user to **register again**, permanently losing the existing data.
1027    #[error("this device is not registered or logged in")]
1028    DeviceNotRegistered,
1029
1030    /// The declared device identity is not the local one.
1031    #[error(
1032        "device identity mismatch: the peer's declared identity differs from the locally obtained one"
1033    )]
1034    DeviceIdentityMismatch,
1035
1036    /// Writing the master key to platform storage failed.
1037    ///
1038    /// `reason` is a **fixed string** and must not contain key material: it is logged, and this is
1039    /// one of the very few paths that touch the master key.
1040    #[error("cannot write the master key to platform storage: {reason}")]
1041    SecureStoreWriteFailed {
1042        /// Developer-facing reason, containing no secret.
1043        reason: &'static str,
1044    },
1045
1046    /// The timestamp is `0`.
1047    ///
1048    /// `0` usually means the caller forgot to pass a time (this library never reads a clock; time
1049    /// always comes from the caller). Letting such a value into the vault pollutes the
1050    /// time-ordered merge logic, and the result is consistently wrong and hard to trace.
1051    #[error("timestamp must not be 0")]
1052    TimestampZero,
1053
1054    // ── Sync ──────────────────────────────────────────────────────────────────
1055    /// The magic number of the sync payload does not match.
1056    ///
1057    /// Separate from "the payload is corrupt": a bad magic usually means **this is not a sync
1058    /// payload at all** (the user picked the wrong file, or another protocol's data was fed in),
1059    /// which points the investigation in a completely different direction than "a payload
1060    /// produced by this application is broken".
1061    #[error("not a Qualia2FA sync payload")]
1062    SyncBadMagic,
1063
1064    /// The sync payload format version is not supported.
1065    #[error("unsupported sync payload version {version}")]
1066    SyncUnsupportedVersion {
1067        /// Version declared by the payload.
1068        version: u8,
1069    },
1070
1071    /// The payload is empty.
1072    ///
1073    /// Reported whether it is discovered in the compression layer or in the outer envelope.
1074    /// "There is no payload at all" and "the payload is broken" point in completely different
1075    /// directions when investigating: the former is usually a caller passing an empty buffer, the
1076    /// latter a peer or transport problem.
1077    #[error("sync payload is empty")]
1078    SyncEmptyPayload,
1079
1080    /// The payload is truncated.
1081    #[error("sync payload is truncated")]
1082    SyncTruncated,
1083
1084    /// The record count declared in the payload does not match its actual content.
1085    ///
1086    /// Separate from [`Error::SyncTruncated`]: truncation means "not enough bytes", this one means
1087    /// "enough bytes, but they do not add up" — the payload was reassembled, a stronger signal
1088    /// than truncation.
1089    #[error("sync payload declares {declared} records but only {actual} were decoded")]
1090    SyncRecordCountMismatch {
1091        /// Record count declared by the payload.
1092        declared: usize,
1093        /// Record count actually decoded.
1094        actual: usize,
1095    },
1096
1097    /// The payload contains more records than the limit allows.
1098    #[error("sync payload record count {count} exceeds the limit {limit}")]
1099    SyncTooManyRecords {
1100        /// Record count declared by the payload.
1101        count: usize,
1102        /// Allowed limit.
1103        limit: usize,
1104    },
1105
1106    /// Compression failed.
1107    ///
1108    /// Deflate is defined for every input, so this does not occur in practice. It exists so that
1109    /// the return value of `miniz_oxide` is not `unwrap`ped.
1110    #[error("sync payload compression failed")]
1111    SyncCompressionFailed,
1112
1113    /// The decompressed content exceeds [`MAX_DECOMPRESSED_LEN`], or the input is not a valid
1114    /// deflate stream at all.
1115    ///
1116    /// [`MAX_DECOMPRESSED_LEN`]: crate::sync::compress::MAX_DECOMPRESSED_LEN
1117    ///
1118    /// Corruption, truncation and a length mismatch deliberately share one variant: their handling
1119    /// is identical (reject this sync, ask the peer to resend), and separating them has no
1120    /// actionable value for the user.
1121    #[error("sync payload decompression failed")]
1122    SyncDecompressionFailed,
1123
1124    /// The payload exceeds the length limit after decompression.
1125    ///
1126    /// # Why it is separate from [`Error::SyncDecompressionFailed`]
1127    ///
1128    /// Because it is the only cause in that group that does **not** represent an attack. A user who
1129    /// really has seventy thousand entries will hit the limit, and telling them "the payload is
1130    /// corrupt" would send them to check the network, the devices and the pairing — none of which
1131    /// is the problem. Telling them "too large, use the LAN instead" is actionable.
1132    ///
1133    /// The price is that an attacker can probe the limit's position with one bit, and the limit is
1134    /// in the open-source code anyway. A related fact: decompression happens **after** the outer
1135    /// AEAD, so input reaching this point has already been authenticated.
1136    #[error(
1137        "sync payload exceeds the {limit} byte limit after decompression; use LAN mode instead"
1138    )]
1139    SyncPayloadTooLarge {
1140        /// The limit, attached to the error so callers need not look up the constant.
1141        limit: usize,
1142    },
1143
1144    /// The payload contains an unknown record kind.
1145    ///
1146    /// As with the equivalent vault-file error, an unknown kind is rejected rather than skipped:
1147    /// skipping makes semantics added by a future version **silently disappear** on older
1148    /// versions, and "the sync finished but a few entries are missing" is one of the hardest
1149    /// failures to trace.
1150    #[error("sync payload contains an unknown record kind {value:#04x}")]
1151    SyncUnknownRecordKind {
1152        /// The kind byte that was read.
1153        value: u8,
1154    },
1155
1156    /// The payload contains an unexplained field or structure.
1157    ///
1158    /// This variant is the **catch-all**: every structural problem that can be named has its own
1159    /// variant, so reaching this one means "the payload failed a self-consistency check, but which
1160    /// one cannot be said".
1161    #[error("malformed sync payload: {reason}")]
1162    SyncMalformed {
1163        /// Human-readable reason for logs and diagnostics. **Must not contain secret material.**
1164        reason: &'static str,
1165    },
1166
1167    /// Authentication of the payload's outer layer failed.
1168    ///
1169    /// Separate from [`Error::VaultDecryptionFailed`]: that one means "the vault file cannot be
1170    /// opened", this one means "this sync payload is untrustworthy". The client handles them
1171    /// differently — the former asks the user to check the master key, the latter only needs "this
1172    /// sync failed, please retry".
1173    ///
1174    /// **It does not distinguish** a wrong master key, a tampered payload and a mismatched sender
1175    /// identifier: telling them apart helps an attacker and has no actionable value for the user.
1176    #[error("sync payload authentication failed")]
1177    SyncAuthenticationFailed,
1178
1179    /// The vault holds entries with icons and the channel cannot carry them.
1180    ///
1181    /// An icon is a picture of up to 64 KiB, and a channel with a declared message limit (a QR
1182    /// code, for instance) cannot hold a payload that carries one. The refusal happens **before
1183    /// anything is sealed**, so the user is told "this channel cannot move icons" instead of
1184    /// seeing a capacity error that says nothing about icons, and the icon is never silently
1185    /// dropped: a sync that quietly loses what the user sees is worse than one that is refused.
1186    ///
1187    /// The fix is not to shrink the icon here: the byte-stream channel (a LAN connection) has no
1188    /// message limit and carries the vault as it is.
1189    #[error("this channel cannot carry entries with icons; use a connection without a size limit")]
1190    SyncIconRequiresByteTransport,
1191
1192    /// This sync payload has already been processed.
1193    ///
1194    /// Replaying an old payload is harmless in itself (merging is idempotent), but **doing a full
1195    /// decryption and merge for nothing** is real cost, and it misleads the "last synced at" shown
1196    /// in the UI. Payload identifiers that have been seen are therefore remembered, and duplicates
1197    /// rejected.
1198    ///
1199    /// This is a **separate error** rather than a silent success: the caller needs to know "the
1200    /// sync did happen, this one is a duplicate", not "a sync just succeeded".
1201    #[error("this sync payload has already been applied")]
1202    SyncAlreadyApplied,
1203
1204    /// The log of used payload identifiers is full.
1205    ///
1206    /// Failing explicitly beats silently dropping the oldest entry: a silent drop makes a very old
1207    /// payload acceptable again, which is exactly what replay protection must prevent. On seeing
1208    /// this error the caller should tell the user that the sync history is too long; what to do
1209    /// about it is a higher-level policy decision.
1210    #[error("the log of used sync payload identifiers is full (limit {limit})")]
1211    SyncIdLogFull {
1212        /// Maximum number of entries allowed.
1213        limit: usize,
1214    },
1215
1216    // ── Transport layer ──
1217    /// The transport channel itself failed (disconnect, timeout, platform refusal).
1218    ///
1219    /// # Why the underlying error is not passed through
1220    ///
1221    /// The underlying failures are things like `std::io::Error` or platform permission errors —
1222    /// **abstractions that do not belong to this crate**. This crate has
1223    /// `#![forbid(unsafe_code)]` and depends on no I/O, so writing those types into the error enum
1224    /// would freeze platform details into the protocol layer. Only an opaque `reason` is kept — it
1225    /// is meant for logs, not for program decisions.
1226    #[error("transport failed: {reason}")]
1227    TransportFailed {
1228        /// Opaque description for logs.
1229        reason: &'static str,
1230    },
1231
1232    /// The device identifier reported by the peer disagrees with the sender claimed by the
1233    /// transport channel.
1234    ///
1235    /// # Why this check must exist
1236    ///
1237    /// The `sender` inside the payload is **self-reported**, and its presence in the AAD only
1238    /// guarantees that no third party changed it. On a broadcast channel (LAN multicast) any
1239    /// device can replay a payload **signed by somebody else** verbatim, so the payload says B
1240    /// while C actually sent it. This check takes "who sent this" from the transport channel and
1241    /// compares it with the value the payload claims.
1242    ///
1243    /// Note that it stops **impersonation**, not replay — replay is stopped by
1244    /// [`Error::SyncAlreadyApplied`].
1245    #[error("the sender reported by the payload differs from the sender claimed by the channel")]
1246    SyncSenderMismatch,
1247
1248    /// The kind claimed by the payload is not one this version knows.
1249    ///
1250    /// Separate from [`Error::SyncUnknownRecordKind`]: that one is about the kind of **one record
1251    /// inside** the payload (entry or tombstone), this one about the kind of **the whole payload**
1252    /// (state exchange or request). See [`crate::sync::PayloadKind`].
1253    #[error("unknown payload kind {value:#04x}")]
1254    SyncUnknownPayloadKind {
1255        /// Raw byte from the wire format.
1256        value: u8,
1257    },
1258
1259    /// The transport channel does not support this operation.
1260    ///
1261    /// For example, a channel that can carry only a single message (a QR code) is asked to open a
1262    /// long-lived connection. It is **not** used for "this channel cannot fit the payload" — that
1263    /// is [`Error::TransportMessageTooLarge`], and the two are handled quite differently (the
1264    /// former can only switch channels; the latter can switch modes or send the user to the LAN).
1265    #[error("the transport channel does not support this operation: {operation}")]
1266    TransportUnsupported {
1267        /// Name of the rejected operation.
1268        operation: &'static str,
1269    },
1270    /// The one-shot channel has already been used.
1271    ///
1272    /// A QR code is the typical one-shot channel: one code carries exactly one exchange. Treating
1273    /// it as reusable would turn "scan the same code twice" into two syncs, which is a side path
1274    /// around replay protection.
1275    #[error("this transport channel has already been used and cannot be reused")]
1276    TransportAlreadyUsed,
1277
1278    /// A single message exceeds what the channel can carry.
1279    ///
1280    /// The usual trigger is a QR code: it is a **fixed-capacity** channel, so when the message does
1281    /// not fit the only options are switching mode or switching channel. The caller should use this
1282    /// to steer the user towards the LAN, not truncate the payload — a truncated payload fails
1283    /// AEAD, so what the user sees is "corrupted data".
1284    #[error("a single message needs {needed} bytes, exceeding this channel's {limit} byte limit")]
1285    TransportMessageTooLarge {
1286        /// Number of bytes this message needs.
1287        needed: usize,
1288        /// Limit declared by the channel.
1289        limit: usize,
1290    },
1291
1292    /// No joinable sync session was found.
1293    ///
1294    /// Separate from "found one, but the handshake failed": the former is the normal "nobody
1295    /// nearby", where the caller should let the user retry or check the network; only the latter is
1296    /// an anomaly.
1297    #[error("no joinable sync session was found")]
1298    SyncNoSessionFound,
1299}
1300
1301/// The unified result type of this library.
1302pub type Result<T> = core::result::Result<T, Error>;
1303
1304#[cfg(test)]
1305mod tests {
1306    use super::*;
1307
1308    /// The displayed error text must contain the numbers needed to locate the problem, and must
1309    /// not panic.
1310    #[test]
1311    fn display_includes_diagnostic_numbers() {
1312        let cases: [(Error, &str); 3] = [
1313            (Error::Base32InvalidCharacter { index: 7 }, "7"),
1314            (Error::Base32InvalidLength { length: 3 }, "3"),
1315            (
1316                Error::SecretTooShort {
1317                    required: 20,
1318                    actual: 4,
1319                },
1320                "20",
1321            ),
1322        ];
1323        for (err, needle) in cases {
1324            let text = err.to_string();
1325            assert!(
1326                text.contains(needle),
1327                "error text {text:?} does not contain {needle:?}"
1328            );
1329        }
1330    }
1331
1332    /// `Error` must implement `std::error::Error`, so that callers can chain it with `?` and
1333    /// `source()`.
1334    #[test]
1335    fn implements_std_error() {
1336        fn assert_is_std_error<E: std::error::Error>() {}
1337        assert_is_std_error::<Error>();
1338    }
1339}