Veyra 1.26.36

Release pack published: 28.07.2026

Windows

Android

iOS

Linux

macOS

Release notes

Release 1.26.36

a release announcement is signed, not just fetched
- latest.json is signed with an offline release key and verified before anything in it is used. HTTPS says the bytes came from the site; it says nothing if the site, its CDN or its TLS terminator is what was compromised — and the manifest decides which version users are told about and which URL the About button opens. The client now verifies an Ed25519 signature against a public key compiled into it, and drops the manifest whole if the signature is missing or does not verify. The host allowlist added in 1.26.35 stays: it is the check that does not depend on key hygiene.
- What the signature covers is defined once, in update_manifest.rs, and that file is included verbatim by the signer — signer and verifier cannot drift apart. The signed form is an explicit rendering of the fields (platform entries sorted, separators refused rather than escaped) rather than the raw JSON, so the site re-serializing the file does not break the signature, while every field the client acts on is covered. A test signs a fixture with the real tool and the real key and verifies it against the real pinned key, so a change to the signed bytes cannot pass unnoticed.
- tools/release-signer — a small offline tool with keygen, sign and verify. The private key lives outside the repository (release-signing/update-manifest.key, git-ignored, added to the savebackup inventory), and releasepack now signs the manifest as part of publishing and then verifies it against the key parsed out of the client source. A release cannot ship an unsigned manifest, and a key/pin mismatch fails before upload rather than after.

---
Version history

Version 1.26.35
the declared limits are the enforced limits
- An inbound attachment manifest is now validated as one object. Every field in it is the sender's: only media_kind was distrusted (and only for policy), while the rest went into the database close to verbatim. A manifest is now accepted only if the transfer id is safe as a directory name, the file name is non-empty and bounded, MIME and media-kind are bounded and free of control characters, the size fits the 1 GiB policy cap, the chunk size is positive and no larger than the wire chunk, the chunk count is exactly what that size and chunk size imply and stays under the transfer-count bound, and the file key and SHA-256 both decode to exactly 32 bytes. A manifest that fails anything rejects the message rather than storing the fields that happen to look plausible. Quote metadata is bounded the same way.
- The wire limits next to the protocol structs were decoration. Every request/response protocol ran on the library's CBOR behaviour, whose codec keeps the library's defaults (1 MiB / 10 MiB) and cannot be configured from outside that crate, so MAX_DIRECT_REQUEST_BYTES and friends were constants nobody enforced — worse than no constant, because they read like a bound. Direct messages, file chunks and catalog exchange now run on our own bounded codec with those exact numbers, and an oversize payload is reported as an overrun instead of being truncated into a decode error. The encoding is byte-for-byte the library's, so this is not a wire change. The remaining protocols stay on the library codec and its documented defaults.
- A .vmb backup is treated as attacker-supplied, because importing one means opening a file someone sent you. The container is size-checked before it is read from disk, must hold exactly the one entry we write, and the entry's declared size is checked before inflation and the inflated size again after — the old parser cloned the whole archive and read the entry to the end with no bound at all.

Version 1.26.34
the tray says Veyra
- The desktop tray tooltip still read "Veyra Messenger". The app has been named Veyra on every platform since 2026-06-09, and the tray was the last place that had not been told: both the initial tooltip and the fallback used when a status tooltip is empty. Found by the 2026-07-27 code review.
- next-steps/README.md header was two versions behind (1.26.30), and the code review itself was sitting in the roadmap directory unindexed — against that index's own rule that every plan in it is listed. Both corrected.

Version 1.26.33
an attachment's name is the sender's, the path is ours
- A file name could carry a second command on Windows. «Открыть» on a received attachment ran cmd /C start "" <path>, and cmd.exe parses what it is given: a name containing & ended the start command and began another one. Opening now goes through the opener plugin, which calls ShellExecuteExW with the path as a wide string — there is no shell left in the path to inject into. Names with &, ^ or % are ordinary Windows names again and are kept as sent.
- A file name could leave the shared directory. The name was joined into the export directory as-is, so .., a leading /, a C:\ prefix or a Windows device name (CON, NUL, COM1) decided where the decrypted file landed. All export and share paths now run the name through one sanitiser (storage::attachment_names): a single component, no separators, no device names, no control characters, length-capped with the extension kept. The file also no longer replaces an existing one — a taken name gets a (2) suffix. The same guard covers the sender's transfer_id, which is a directory component under attachments/.
- A malformed nonce killed the p2p event task. Nonce::from_slice panics on anything but 12 bytes, and inbound envelopes and file chunks reached it straight from base64. The envelope signature does not cover the nonce, so a validly signed message with a 3-byte nonce was enough. The length is now checked before the conversion and a bad one fails that message (MessageDecryptionFailed) instead of the task carrying it. Regression tests cover all three: names that try to escape a directory stay inside it, shell metacharacters survive intact, reserved and overlong names, and nonce lengths 0/1/11/13/24/4096 at both the crypto and the envelope layer.

Version 1.26.32
"The proxy is not responding" stops crying wolf
- The proxy-outage dialog appeared repeatedly while the proxy was working. Diagnosed from the ASUS field log: 876 successful HTTPS requests against 117 failures, and replaying that exact event stream through the old health machine raises 18 dialogs. Three separate defects stacked up, all three fixed here. - "Two consecutive failures" counted failures that were not consecutive. The mailbox HTTPS pool runs several workers in parallel, so one bad moment arrives as several failures microseconds apart — the log has a pair 0.5 ms apart with a successful request one second later. A run of failures must now also span at least 20 seconds to count as anything at all. - Every flap minted a new outage id. The UI shows one dialog per outage id and then stays quiet, so a stuttering minute produced a stack of dialogs (five in four minutes in the log). A proxy that recovers and stumbles again within 15 minutes is still the outage the user has already been told about. - The proxy was judged by whether OUR bootstrap node answered. A node that is down, slow or blocked says nothing about whether the tunnel carries traffic. This is the same reasoning that already keeps the manual check off our own nodes (ratified 2026-07-25, after it condemned a healthy proxy over one unroutable node) — the passive path had kept the old sin. A sustained run of failures now only opens a question, and a neutral probe against the PROBE_TARGETS set answers it: if the probe gets through, the proxy works, the failures belonged to whatever we were talking to, and nothing is shown. Only a failed probe condemns the proxy. A verdict that arrives after a success is discarded — it describes a state the app has already left. Replaying the same field log through the new machine: 4 probes instead of 18 dialogs, and at most 3 dialogs even if every single probe also failed — zero if the probe gets through, which is what the 876 successes say would happen.

Version 1.26.31
iOS launch crash from the 1.26.28 keep-screen-on toggle
- The app installed and then died on launch on iOS (EXC_BREAKPOINT in assertBarrierOnQueue). UIApplication is main-thread-only, and -[UIApplication _setIdleTimerDisabled:forReason:] kills the process when it is touched from anywhere else. 1.26.28 seeded the stored keep-screen-on preference from the startup schema warm-up, which is its own std::thread — so the very first thing the new code did on launch was call UIKit off the main thread. Every path to the idle timer now hops through AppHandle::run_on_main_thread, which also covers the two call sites that were already off-thread before this toggle existed: the Resumed/Suspended window events and the notify_app_resumed command (Tauri commands run on worker threads). set_ios_idle_timer_disabled is private now, so the main-thread hop is the only door to it and a future caller cannot reintroduce the crash. Caught only on device: this branch is #[cfg(target_os = "ios")] and cannot be compiled, let alone run, from the Windows dev machine — the 1.26.28 notes said the iOS branch was unverified, and this is exactly what it cost.