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}