Changelog
All notable changes to the VAULTEX project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[0.13.1] - 2026-06-15
Added
- iOS app — messaging works (MVP). The SwiftUI iOS app is now a functional messenger: create/unlock an identity (passphrase, PBKDF2-verified), add contacts by identity key, and 1:1 relay messaging end-to-end. The crypto FFI (X3DH, Double Ratchet, sealed sender) runs on-device and the full register → encrypt → relay → decrypt path is verified live against the production server. Builds for the iOS Simulator (Xcode 26 / iOS 26.5); 19 XCTests + 2 live integration tests pass. (Transports, voice/video calls, and APNs are a later pass.)
- macOS desktop build. The Tauri desktop app now builds a macOS
.app(cargo tauri build --bundles app); same app as Windows/Linux. - Android "Find on Server" (opt-in discovery). Browse the server directory by display name and add contacts with fingerprint confirmation, instead of pasting 64-char identity keys (Settings → Discoverable toggle + display name; Chats → Add contact → Find on Server). Brings the desktop discovery feature to Android.
Fixed
- Desktop: contacts vanished on restart. Contacts were persisted but the frontend never reloaded them into the store after unlock — now
loadContacts()/loadBlockedContacts()run on auth. - Android: PIN-protected unlock was a dead-end. With a PIN set, the correct passphrase appeared "not recognized" and the PIN screen was never reached. A correct passphrase now always unlocks; the PIN is a reachable quick-unlock with a "use passphrase instead" fallback (the duress-PIN wipe is unchanged).
- iOS: authenticated REST + relay send. Fixed the iOS REST client to Ed25519-sign requests (the server requires signed requests, not bearer tokens) and to authenticate
/messages/send— without which every authenticated iOS call would 401. Also corrected several wrong server route paths.
[0.13.0] - 2026-06-13
Added
- Android background calling — incoming calls ring when the app is closed. A foreground service keeps the relay connection alive so a closed (or backgrounded) device still rings, with a full-screen
CallStylenotification (Answer / Decline). Deliberately no FCM: a third-party push service would learn call metadata (who/when), so VAULTEX holds its own end-to-end-encrypted signaling connection instead — preserving the zero-knowledge guarantee.ConnectionHolder: app/process-scoped owner of the single relay WebSocket (previously ViewModel-scoped, so it died with the Activity). Routes call signaling straight to the app-scopedCallManagerso calls ring with no UI alive.CallSignalingService: foreground service (specialUse|microphone) showing a low-priority "VAULTEX active" notification; started on login.- Settings → Background calling toggle (default on).
POST_NOTIFICATIONSrequested at runtime. Duress-wipe and re-register tear down the service + connection.
Testing
- The Windows→Android live-call harness gained a
VAULTEX_BG=1path that backgrounds the app before the call and asserts the offer reachesCallManager, the full-screen ring posts, and answering via the notification connects both ends. Verified PASS (alongside the unchanged foreground path).
Known limitations
- Calls ring across backgrounding and Activity death, but not a hard force-stop or an OS low-memory kill of the foreground service (rare) — the tradeoff of the FCM-free design.
- Video calls are not implemented (voice only). Linux desktop calls remain signaling-only.
[0.12.0] - 2026-06-13
Added
- Android voice calling (1:1). Native WebRTC audio on Android, interoperable with the desktop. A real call from the Windows desktop connects to a physical Android device with both ends reaching a connected
RTCPeerConnection(Opus + DTLS-SRTP); the relay only forwards signaling.apps/android/.../calls/CallManager.kt: app-scoped WebRTC engine (io.getstream:stream-webrtc-android) — onePeerConnectionpercall_id, mic capture, STUN, ICE trickle, and a connection-state-drivenconnectedtransition. Mirrors the desktopwebrtc.ts/callStore.tsso Android ↔ desktop calls interop.- Inbound
CallOffer/CallAnswer/IceCandidate/CallHangup/CallRejectare routed fromWebSocketClient→MessagingManager→CallManager; a global incoming/active-call overlay (MainActivity) surfaces a call from any screen and gates answering on aRECORD_AUDIOruntime request. - Outgoing calls from Android: the conversation call button (
ChatScreen.onCallClick) is wired toCallManager.startOutgoing, mic-gated, so Android can initiate as well as answer.
Security
- SDP/ICE are authenticated-encrypted end-to-end on Android too (libsodium
crypto_boxkeyed by the parties' identity keys, via the newffi_encrypt_to_identity/ffi_decrypt_from_identityFFI). The relay sees only ciphertext. Answers and ICE are authenticated against the peer's identity key from the local call record, not the relay-stamped sender id (defeats a relay MITM); replayedcall_idoffers are dropped. Passed an internal Security Engineer sign-off (added rigor; not a substitute for the planned third-party audit).
Testing
- Windows → Android live-call acceptance harness (
scripts/test/run-android-call.ps1+apps/desktop/e2e/cross-device-android-call.ts+apps/android/maestro/x_add_one_contact.yaml,x_answer_call.yaml): drives a real desktop→device call (tauri-driver + Maestro +adb logcat) and asserts both ends reach a connectedRTCPeerConnection. Verified PASS.
Known limitations
- Incoming calls do not survive app process death. The pending offer is held in the
CallManagersingleton (in-memory, not persisted), so if the app is killed it cannot be rung back. A foreground service / push-notification path is the planned follow-up. - Video calls are not implemented (voice only).
- Linux desktop calls remain signaling-only (see v0.11.0).
[0.11.0] - 2026-06-12
Added
- Desktop voice calling (1:1). Full call lifecycle on the desktop app: ring/answer/reject/hangup signaling relayed over the WebSocket, and real peer-to-peer WebRTC audio media (Opus + DTLS-SRTP) running in the WebView engine. The relay only forwards signaling; media flows device-to-device.
- Call commands (
start_call/answer_call/reject_call/hangup_call/send_ice_candidate) wired to the relay; inboundCallOffer/CallAnswer/IceCandidate/CallHangup/CallRejectdrivecallStore+ theRTCPeerConnection.call_idis frontend-generated so ICE can trickle immediately. apps/desktop/src/lib/webrtc.ts: oneRTCPeerConnectionper call, mic capture (with a timeout + synthetic/recvonly fallback so a missing mic or permission prompt can't freeze a call), ICE trickling, remote<audio>, and a connection-state-drivenconnectedtransition.
- Call commands (
Security
- SDP/ICE are authenticated-encrypted end-to-end (libsodium
crypto_boxkeyed by the parties' identity keys, X25519). The relay sees only ciphertext and cannot read or forge call signaling — SDP/ICE carry IP addresses, so this preserves the zero-knowledge guarantee. The answer/ICE are authenticated against the peer from the local call record, not the relay-stamped sender id (defeats a relay MITM). Inbound offers with a replayedcall_idare dropped. Passed an internal Security Engineer sign-off (added rigor; not a substitute for the planned third-party audit).
Known limitations
- Linux desktop calls are signaling-only. WebKitGTK does not expose
RTCPeerConnectionthrough Tauri/wry today (the WebRTC runtime feature is fixed before the setup hook runs; confirmed not a GPU issue), so Linux cannot establish call media yet. Tracked as a follow-up (needs a patched wry and/or a WebRTC-enabled WebKitGTK). Windows/macOS WebView engines support it. - Video calls are not implemented (voice only).
[0.10.13] - 2026-06-09
Fixed
Android: Double Ratchet now wired into the relay-server message path. Closes the gap documented in v0.10.12's "Known limitations" block. Both directions work end-to-end with the Pass-2 3-way harness (two consecutive PASS runs):
- Receive (
MessagingManager.onMessageInner→RelayCrypto.decryptIncoming): parses the Rust wire envelope{header, ciphertext, x3dh_init?}, runsx3dhAcceptagainst our stored signed-prekey + one-time-prekey (consuming the named OTPK on success), initializes a receiver ratchet, decrypts with AD set to our ownaccount_id, persists the updated session in thesessionstable, then unwraps the innerMessagePayloadfor the chat list. - Send (
MessagingManager.sendMessage→RelayCrypto.encryptOutgoing): lazy session establishment — if no session exists for the recipient, fetches their prekey bundle from/api/v1/accounts/<id>/prekey_bundle, runsx3dhInitiate, initializes a sender ratchet, encrypts theMessagePayload, and embeds thex3dh_initfield so the receiver can complete the handshake. On subsequent sends, encrypts with the existing session. AD = recipient'saccount_id. Wire envelope isserde_json-compatible with what the Rust desktop emits — desktop ↔ Android interop verified against W ↔ A and L ↔ A 1:1 flows. - Legacy plaintext-JSON receive (fallback) path is preserved so two older Android peers still interop while the network rolls forward.
- Receive (
Android: group auto-discovery on incoming Double Ratchet messages. When a decrypted
MessagePayloadhas agroup_id,MessagingManager.handleIncomingGroupMessagecreates thegroupsrow if it's new (using the desktop-side naming conventionGroup <first8-hex>), seeds initial members (us + sender), and inserts the message intogroup_messageskeyed by the discovered group. Matches the desktop'scommands/messages.rs::receive_message_innerauto-discover path. Without this, group messages from desktop arrived but never surfaced in the Android Groups tab.
Added
apps/android/.../messaging/RelayCrypto.kt: the new Double Ratchet wire codec for the relay path. Encapsulates parse → x3dhAccept → ratchetInitReceiver → ratchetDecrypt on the receive side and prekey-bundle-fetch → x3dhInitiate → ratchetInitSender → ratchetEncrypt on the send side. Designed to be independent ofP2PSessionManager(which uses a different binary wire format for over-the-air P2P) but shares the samesessionstable so a session established via one transport keeps ratcheting cleanly over the other.KeyDao.getOneTimePreKey(id): needed by the receive path to look up the OTPK named in the sender'sx3dh_init.used_one_time_prekey_idfield.
Changed
- Pass-2 3-way harness (
scripts/test/run-cross-device-3way.ps1+apps/android/maestro/x_*.yaml): now passes end-to-end against the Android + Windows + Linux trio. Covers register, contact exchange, all four 1:1 cross-device pairs (W↔A, L↔A), and a 3-member group with auto-discovery on every receiver.
[0.10.12] - 2026-06-09
Fixed
- Desktop: cross-device group messages now actually surface on the receiver's UI. Root cause:
apps/desktop/src-tauri/capabilities/default.jsondid not exist. In Tauri 2.x, the absence of any capability file means the plugin-IPC ACL blocks every plugin command includingevent:listen. Every JS-sidelisten(...)call in the app (message-decryptedinmessagesStore, the newgroups-updatedingroupsStore) was silently rejected withCommand plugin:event|listen not allowed by ACL. The Rust side decrypted messages and emitted events fine — the React UI never received them, so groups never auto-discovered and live message updates depended on whatever IPC the user happened to trigger by clicking around. Added adefaultcapability withcore:default+shell:allow-openpermissions, which unblocks the event system. - Desktop: groups-updated event has a JS listener now.
commands/messages.rs::receive_message_inner(auto-discover) andwebsocket.rs(post-decrypt) bothapp.emit("groups-updated", ())after a group-tagged message. Until v0.10.12 no JS code listened — the emit was load-bearing dead code. AddeduseGroupsStore.setupEventListeners()wired fromApp.tsxso the sidebar refreshes the moment the Rust side discovers a new group from an incoming message. - Desktop:
RegisterScreenseed-phrase / PIN steps were unreachable.authStore.register()setsisAuthenticated: trueimmediately on backend success, which causesApp.tsxto unmountRegisterScreenbefore the post-passphrasesetStep('seed')even paints. The seed-confirm and PIN-setup screens were effectively dead code. Documented as a known issue (the acceptance test now expects the post-passphrase flow to land directly on the main app); a follow-up will move the seed reveal to a forced one-time modal so users can't miss their recovery phrase.
Added
- Acceptance test: Pass-2 cross-device W ↔ L group send harness (
apps/desktop/e2e/cross-device-group.ts+scripts/test/run-cross-device.ps1+scripts/test/linux-laptop-bringup.sh). Drives two webdriver sessions concurrently — Windows via local tauri-driver, Linux laptop via SSH-tunnelled tauri-driver — through the full register → add-contact → create-group → send → verify-receipt flow with the message body asserted on the receiver. Uses raw fetch against the W3C WebDriver wire (not webdriverio) because webdriverio v9 unconditionally requests BiDi (webSocketUrl: true) and WebKitWebDriver rejects it. Captures[VAULTEX][groups]println!s from the Linux side via a stdout-redirecting wrapper script. The harness was developed against this exact bug (Tauri ACL blocking event listeners); the failing run reproduced the user's symptom before the fix and a passing run confirmed it. - Acceptance test: Pass-2 3-way cross-device harness (
apps/desktop/e2e/cross-device-3way.ts+scripts/test/run-cross-device-3way.ps1+apps/android/maestro/x_*.yaml). Extends the W↔L harness to include the Android tablet driven by Windows-native Maestro (Java 17, talks directly to the Windows adb-server). Android identity key is pulled from logcat via aLog.i("VAULTEX-TEST", "IDENTITY_KEY=$keyHex")test hook added toAuthViewModel.kt. The harness wakes the tablet,pm clears app state, then registers / adds contacts / sends + verifies messages across all three devices. - Desktop:
groupsStore.setupEventListeners()that subscribes togroups-updatedand refreshes viagetGroups(). Idempotent; safe to call multiple times. - Desktop:
window.__VAULTEX_GROUPS_STORE__test hook. The Pass-2 harness uses this to read the zustand store state and force agetGroups()call when debugging refresh issues. Exposes already-rendered state only — no escalation surface.
Changed
- Desktop:
websocket.rslogs thegroups-updatedemit result (println!("[VAULTEX][groups] groups-updated emit result: {:?}", ...)) so future regressions in the event delivery path are visible from the binary's stdout. - Android:
AuthViewModel.checkExistingIdentity()+ register: emitsLog.i("VAULTEX-TEST", "IDENTITY_KEY=<hex>")once the identity is resolved, so the Pass-2 harness can pull it without parsing SQLCipher-encrypted Room storage. Debug-only side effect (one line per launch); not security-sensitive (the identity public key is already shown in the Settings UI and copied to clipboard from there).
Known limitations (Android relay-server messaging — flagged here so it isn't quiet)
- Android does NOT yet wire the Double Ratchet into its relay-server message path. The Pass-2 3-way harness caught this in W → A 1:1: the Android receiver displays the desktop's encrypted envelope as raw JSON text instead of decrypting it (
apps/android/app/src/main/java/com/vaultex/app/messaging/MessagingManager.kt:430has a literal// TODO: Decrypt with Double Ratchet via FFI). The send path is symmetric — Android wraps the message body as a plaintext{message_id, content, ttl}JSON envelope and ships it over the relay, never calling the encrypt FFI. Implications: (1) Desktop ↔ Android relay messaging in either direction is currently broken or unsafe, (2) Android ↔ Android relay messaging works but is not zero-knowledge — the relay sees plaintext. The crypto FFI bindings (x3dhAccept,ratchetInitReceiver,ratchetDecrypt, plus the send-side equivalents) already exist inVaultexCrypto.kt; they're just not wired intoMessagingManager.onMessageInner/sendMessage. Tracked as follow-up task #72. Until that lands, Android cross-device messaging should be considered broken. P2P transport (WiFi Direct / LAN / BLE) does callsessionManager.encryptand is unaffected.
[0.10.11] - 2026-06-08
Fixed
- Desktop: client version chip in Settings was always blank. v0.10.10 used a lazy
import('@tauri-apps/api/app')to keep the module out of the test bundle, then swallowed any error with.catch(() => {}). In the packaged production shell the dynamic import never resolved (the Vite import map doesn't carry it forward into the bundled HTML), but the silent catch hid that. Switch to a static top-of-fileimport { getVersion } from '@tauri-apps/api/app'so Rollup pulls the module in, and render the error message inline in the About row ifgetVersion()ever does throw — no more silent failures.
Added
- Desktop: stdout-visible diagnostic for group send + receive.
send_group_messageandreceive_message_innernowprintln!at every decision point (members enumerated, encryption attempted per recipient, WebSocket vs HTTP path taken, auto-discover fired, group_id parsed from payload). Launch the binary from a terminal (Linux:vaultex-desktopfrom a shell; Windows:cmd.exe → "%LOCALAPPDATA%\VAULTEX\vaultex-desktop.exe") and you'll see[VAULTEX][groups]lines flowing — those are what to paste when reporting cross-device group bugs.
Known limitations (honest)
- Cross-device group messaging between Windows and Linux is still not verified by the acceptance suite. The Pass-1 Maestro / WDIO / PowerShell-smoke trio covers single-device UI flows only; cross-device delivery (Phase 15 of the test plan) is the open Pass-2 task #65 and was not run before this release. The diagnostic logs in this release are the bridge: please run a W↔L group send/receive with both apps launched from terminals and report the
[VAULTEX][groups]output from both sides if the message still doesn't arrive.
[0.10.10] - 2026-06-08
Fixed
- Desktop: cross-device group messages went into the void. Sending a message to a group on Windows reached Linux's WebSocket handler and decrypted successfully, but
messagesStore.tsonly surfaced it in the Groups panel whenselectedGroupId === groupId— and Linux didn't have the group locally becausecreate_groupuploaded an emptymember_account_idsto the server and the server never pushed group membership notifications back. Two fixes: (1)create_groupnow resolves each member's identity-key-hex to their server account_id via the local contacts table and includes them inServerCreateGroupRequest.member_account_ids, and (2) on the receive sidereceive_message_innerauto-discovers any unknowngroup_idfrom an incoming message and inserts a placeholderStoredGroupInfowith the sender + self as the known members. The frontend emitsgroups-updatedafter auto-discover so the Groups panel refreshes without a manual reload. - Desktop: "Create Group" took a textarea of newline-separated identity keys, no contact picker. Typing a nickname like "Bob" got passed straight through as a "member key" and
send_group_messagesilently skipped because no contact hadidentity_key_hex == "Bob". The Android Groups MVP shipped a proper contact multi-select in v0.10.8; desktop now has the same — a checkbox list of every contact in the local table, with a0/Ncounter, an empty-state hint that points users to the Chats tab if they have no contacts yet, and an 8-char identity-key fingerprint per row so users can disambiguate contacts with the same nickname.
Added
- Desktop: client version chip in Settings. Bottom of the Settings screen now shows a tiny About section with "VAULTEX" + "v0.10.10 — Zero-knowledge encrypted messaging", matching the Android Settings > About row. The version comes from
getVersion()in@tauri-apps/api/app, so it trackstauri.conf.jsonand updates automatically per release.
[0.10.9] - 2026-06-08
Fixed
- Android: tapping the "I have written down my seed phrase" label was a no-op.
RegisterScreen.kt'sSeedSteprendered the checkbox-plus-label as aRowwhere only the bareCheckboxitself handledonCheckedChange; the surroundingRowand theTextlabel had no click handler. A user tapping anywhere on the row except the tiny checkbox hit-target got nothing — including a disabled "Continue" button. Wrapped theRowinModifier.toggleablewithrole = Role.Checkboxso the whole row is the hit-target and TalkBack reads it as a checkbox. Caught byauth_flowin the new Maestro suite. - Android: tapping anywhere off the search bar in Chats didn't dismiss the on-screen keyboard. The Compose
OutlinedTextFieldinChatsScreenheld focus until the user navigated away. The IME is a separate system service so it survives even an activity restart (am force-stop+am start), and the keyboard would stay up covering the bottom nav. Visible to real users as "the keyboard won't go away unless I exit the app"; visible to the acceptance suite as every flow-after-search failing because the bottom nav was hidden. AddedModifier.clickable { focusManager.clearFocus() }to the parentColumnso any outside tap clears the focus. - Android: bottom-nav tab taps couldn't be reliably automated, and on Samsung devices were occasionally routed to the system Settings app.
BottomNavBar.ktnow exposes eachNavigationBarItem'stestTag(nav_tab_chats,nav_tab_groups,nav_tab_calls,nav_tab_settings) as an Android resource-id viaModifier.semantics { testTagsAsResourceId = true }. Accessibility tools and UI automation can now target tabs by stable id; the text-based fallback was matching Samsung's Edge Panel + Bixby surfaces that expose the same strings. - Website: download buttons were stuck on the previous release. The marketing site's
useLatestReleasehook fetches the latest GitHub release at runtime and rewrites the static download links, but the nginx CSP haddefault-src 'self'with noconnect-srcoverride, so thefetchtohttps://api.github.comwas silently blocked. New CSP allowsconnect-src 'self' https://api.github.com. Effect: every published GitHub release now updates the download buttons within one page load — no website rebuild required.
Added
- Comprehensive acceptance test suite.
docs/testing/acceptance-test-plan.mdrewritten to v2.0 (per-platform tags[W]/[L]/[A], honest known-limitations table, Phase 15 cross-device scaffold). Three new tooling layers:- Android (Maestro on Windows): 10 flows covering register → groups MVP → 1:1 messaging → contacts → search → PIN setup → settings. All 10 green on a Samsung Tab S6 Lite over USB. The
auth_flow.yamlexercises the full register-skip-PIN-start-messaging path withclearState; per-flow logs and Maestro debug screenshots land inout/acceptance-<UTC>/android-maestro/. - Windows desktop (PowerShell smoke + WebDriverIO via
tauri-driver):scripts/test/windows-smoke.ps1launches the installed binary, waits for the main window, screenshots it, and kills cleanly (~5s end-to-end).apps/desktop/e2e/ships a WDIO +tauri-driverscaffold; session attach is verified. - Orchestrator:
scripts/test/run-acceptance.ps1chains all three phases and writesout/acceptance-<UTC>/summary.mdwith PASS/FAIL per phase. Drives Maestro on Windows directly (no WSL hop) so it can see USB-connected Android devices via the Windowsadbserver. Auto-discovers a portable Temurin JDK 17 + Maestro distribution under%USERPROFILE%.
- Android (Maestro on Windows): 10 flows covering register → groups MVP → 1:1 messaging → contacts → search → PIN setup → settings. All 10 green on a Samsung Tab S6 Lite over USB. The
Repo hygiene
/out/andapps/desktop/e2e/node_modules/added to.gitignoreso test artifacts don't bloat the repo.
Linux desktop bundle (.deb) still tracks v0.10.3 — built locally on the Linux laptop on next deploy cycle.
[0.10.8] - 2026-06-03
Fixed
- Android: Groups tab was a UI stub — tapping a group did nothing, and "create group" silently produced empty groups with no way to add members or send messages.
GroupsScreen's list item hadonClick = { /* TODO: navigate to group chat */ }andCreateGroupDialog's confirm handler always passedemptyList()for members, so even whenGroupsViewModel.createGroupwas wired to insert members, none were ever provided. Wired a newScreen.GroupChatroute ("group/{groupId}"), rewroteCreateGroupDialogas a contact multi-select with checkmark affordance, and added a fullGroupChatScreen(TopAppBar back nav, member chips with per-member remove, message list with a 1.5s poll while open, send input bar, AddMemberDialog from the remaining contacts).MessagingManager.sendGroupMessage(groupId, content)fan-outs the encrypted envelope to every member's account_id with a stablemessage_idso receivers can dedupe.
Android only — desktop builds remain at 0.10.7.
[0.10.7] - 2026-06-02
Fixed
- Android: app crashed when receiving a message from a peer not in the local contacts table.
MessagingManager.onMessageinserted a row whosecontact_idhad aFOREIGN KEYintocontacts; if the sender wasn't incontacts(first-contact, identity-key reset, queue flush after contact removal) the insert threwSQLiteConstraintExceptionon the worker thread and killed the process. Auto-create a placeholder contact named"Unknown (xxxxxxxx)"and wrap the worker body in a top-level catch so no future DB / decode / decryption error can ever take the process down again. Root cause of the user-reported cascade after the QR-screen incident. - Android: register left old vault data in place.
AuthViewModel.registernever calledDatabaseProvider.wipeAll, so contacts / messages / sessions / signed_prekeys / one_time_prekeys from the prior identity survived. Visible symptoms: old contacts under the new identity, and OTPK PK collision aborting the register half-way (identity written, prekeys not, leaving an unusable vault). Now wipes everything when an existing identity is found before starting register. - Android: register writes are now atomic. Identity + signed prekey + OTPKs + passphrase hash are committed in a single
db.withTransaction { … }. Reordered so passphrase hash writes FIRST inside the transaction — combined with the strict login check below, an OS-kill mid-register can no longer leave the vault in a state where login would silently authenticate. - Android:
login()no longer silently authenticates when the passphrase hash is missing. The pre-fix path skipped the hash check entirely whensettings.passphraseHashwas null and fell through to the authenticated branch. Now returns a clear "vault corrupted, create new identity" error instead. - Android: sealed-sender messages no longer all merge into one inbox.
onSealedMessagepreviously routed every sealed message to a single "unknown" contact key, crossing conversation boundaries. Until real unsealing is wired through the FFI, drop them with a log instead. - Desktop: group messages can now actually decrypt.
send_group_messageencrypted each fan-out withad = member_identity_hexwhile the receive path decrypts withad = account_id, so the AEAD tag check always failed. Resolve members to their account_id before encrypting and use the resolved id as both the sessions key and the AD. Also include the missingx3dh_initfield on the wire so the receiver's lazy session-create branch can run on the first group message to a freshly-added member. - Desktop:
MessagePayload.group_idis now validated and rejected when malformed. A peer could otherwise smuggle structured data (megabyte strings, control chars, IDs of groups the peer was kicked from) through the encrypted field. Reject anything that isn't either the placeholder or exactly 64 ASCII hex chars; on mismatch the message is treated as a 1:1 instead of polluting the local store. - Desktop:
MessagePayload's on-the-wire JSON shape no longer leaks group-vs-DM via padding bucket size. The previousskip_serializing_if = "Option::is_none"meant addinggroup_idpushed mid-sized DMs into the next padding bucket, exposing conversation type to an on-path observer. The padded serializer now always emits the field with a fixed-size placeholder for DMs so the wire shape is invariant.
Security
- FFI: negative length parameters can no longer trigger SIGSEGV or memory disclosure. Every entry that took a
*const u8+i32 lenpair didstd::slice::from_raw_parts(ptr, len as usize); a malicious Java caller passinglen = -1cast tousize::MAXand sliced into unmapped memory.catch_unwinddoes NOT catch SIGSEGV, so the process death was unrecoverable. Addedcrate::error::validate_len(len, name)which rejects negative values and anything above an 8 MiB hard ceiling; applied at every FFI entry point. Worst offender wasffi_secure_random_bytes, where the caller-controlledlendrove BOTH the random draw AND thecopy_nonoverlappinginto the caller's buffer.
Repo hygiene
.gitattributesadded to stopcore.autocrlf=trueon Windows from markingapps/desktop/src-tauri/Cargo.tomlas modified after every operation. Pin LF on source/config files; CRLF on.bat/.cmd/.ps1; binary on the obvious extensions. Followed bygit add --renormalize .to bring the index into agreement.
Android only — desktop builds (Linux .deb, Windows .exe) remain at 0.10.3.
[0.10.6] - 2026-06-02
Fixed
- Android: messages from server contacts were never delivered, even after registration was fixed. The v0.10.5 release fixed Android's server-registration flow but the WebSocket connection that delivers messages was never opened —
NavGraphmaterialized aNetworkManagerViewModel but never calledsetCredentialsorconnect, andMessagingManager.networkManagerstayednull. The desktop-side "Contact added in local-only mode" symptom looked unfixed because the Android client never came online on the server.NavGraphnow wires both managers and triggersnetworkManager.connect(Config.DEFAULT_SERVER_URL)onisAuthenticated. Verified end-to-end: desktop registered (account39c9922e-…), Android registered (account6024dd19-…), desktop adds Android via/accounts/by-keywithout falling back to local-only, desktop sends a Double Ratchet + sealed-sender message, server queues + delivers, Android logcat showsMessagingManager onMessage senderId=39c9922e-… payloadLen=1098. - Android: contact-add resolved a local UUID instead of the server's account_id (also in v0.10.5 but worth restating). The fix in commit
68fee24is now actually exercised on the wire by the WS fix above.
Added
- Website: GitHub Releases-backed download buttons with self-hosted fallback.
useLatestReleasefetches/repos/VaultexCoder/vaultex/releases/latestat runtime and the platform cards swap their URLs in once the API answers (8s budget; static/downloadsURLs are used otherwise). Result: shipping a new binary version no longer requires rebuilding and redeploying the Astro site — just upload assets to a GitHub release and the marketing site picks them up automatically.
Internal
- Diagnostic
Log.iinstrumentation added toNavGraph,NetworkManager,WebSocketClient, andMessagingManagercovering connect lifecycle + server message arrival. Future WS regressions surface from logcat alone, no emulator/Maestro needed.
Android only — desktop builds (Linux .deb, Windows .exe) remain at 0.10.3.
[0.10.5] - 2026-06-02
Fixed
- Android: default server URL was still
http://localhost:8080. The v0.10.3 release notes claimed the localhost default was switched to the production demo server, but that fix only touchedapps/desktop/src-tauri/src/state.rsandapps/desktop/src/stores/networkStore.ts. Three independent hardcoded defaults inNetworkState,ApiClient, andSettingsStateon Android were missed. A fresh APK install therefore tried to reachlocalhost:8080on registration and silently failed with no UI hint. Centralized asConfig.DEFAULT_SERVER_URL = "https://api.vaultexchat.org"so all three sites stay in sync going forward.
Added
- Android Settings → Test Connection button. Pings
/api/v1/healthon the current server URL and shows a 4-second success / failure card. Removes the "is the server reachable?" guesswork that previously could only be answered by attempting registration.
Android only — desktop builds (Linux .deb, Windows .exe) remain at 0.10.3.
[0.10.4] - 2026-06-01
Fixed
- Android: tapping "Generate Identity Key" crashed the app immediately.
VaultexLib.INSTANCE's lazyNative.load("vaultex_ffi", ...)throwsUnsatisfiedLinkError(ajava.lang.Errorsubclass — not anException) whenlibvaultex_ffi.sois missing from the APK, andAuthViewModel.register()'scatch (Exception)blocks did not intercept Errors, so the LinkageError escaped the coroutine and tore down the process. Broadened the three relevant catches toThrowable, and now cross-compile + bundlelibvaultex_ffi.soforarm64-v8aandx86_64so the FFI path actually works rather than just degrading gracefully.
Android only — desktop builds (Linux .deb, Windows .exe) remain at 0.10.3.
[0.10.3] - 2026-06-01
Fixed
Default server URL switched from localhost to the production demo server. A fresh install on a new machine — without any environment variables set, without running
launch-alice.ps1/launch-bob.ps1to override — no longer defaults tohttp://localhost:8080and gets stuck on the login screen with no way to reach Settings. Default is nowhttps://api.vaultexchat.org.VAULTEX_SERVER_URLstill overrides as before.Applies to both the Rust backend (
AppState::newinapps/desktop/src-tauri/src/state.rs) and the React frontend (networkStoreinitial state inapps/desktop/src/stores/networkStore.ts). The two stayed in sync per the v0.10.2get_default_server_urlwiring.Long-term UX fix (allow editing the server URL from the login screen, before registration) is tracked in the project issue tracker — this release is the immediate unblock.
[0.10.2] - 2026-05-31
Fixed
Frontend silently overrode env-derived server URL on first connect:
networkStorehardcodedserverUrl: 'http://localhost:8080'as its initial value, andApp.tsxauto-connect passed that hardcoded value intoconnect_to_server, which OVERWRITESAppState::server_url(the value derived fromVAULTEX_SERVER_URLat startup). The backend then made all session-establish HTTP calls againstlocalhost:8080, so adding a contact returnedsessionStatus = "server_unreachable"and no encrypted messages flowed. Same regression class as the three v0.10.1 fixes (silent local-mode fallback via a different code path).Fix: new
get_default_server_urlTauri command that returns whatever the backend was configured with;networkStore.initServerUrl()reads it and adopts that value;App.tsxawaitsinitServerUrlbefore issuingconnect. Surfaced by the new distributed E2E spec (wdio.distributed.conf.ts).
Added
get_default_server_urlTauri command so the frontend can stay in sync with the backend's resolved server URL without re-reading env vars (which a WebView can't see).
[0.10.1] - 2026-05-30
Fixed
- Multi-client vault collision on a single OS user:
default_data_dir()now honorsVAULTEX_DATA_DIRbefore the platform default, so two desktop instances launched on the same Windows (or any) OS user can hold distinct vaults instead of fighting over%APPDATA%\vaultex. The E2E orchestrator setsVAULTEX_DATA_DIRper client (keptXDG_DATA_HOMEalongside for backward compat). - Silent registration fallback:
registerpreviously fabricated a local UUID when the server rejected the request or was unreachable, leaving the UI showing "registered" against an account the server didn't know. Every later request (WS auth, by-key lookup, discoverability) then quietly 401'd. The command now returns an actionable error and persists nothing, so the user can fix the server URL and retry on a clean vault. - Default server URL ignored env:
AppState::newnow readsVAULTEX_SERVER_URLfirst (falling back tohttp://localhost:8080), so a launcher / CI can point a client at the right server before any UI interaction. - Server Dockerfile build break (#143): bumped builder image from
rust:1.77-bookwormtorust:1-bookworm. The pinned 1.77 Cargo could not parse the workspaceCargo.lock(lock format v4, introduced in Cargo 1.78), sodocker compose build serverfailed immediately. - Desktop Tauri version mismatch (#144): bumped
@tauri-apps/apifrom^2.0.0to^2.11.0so it tracks the Rusttauricrate (resolved to 2.11.x). The Tauri CLI rejects mismatched major/minor versions between the npm package and Rust crate, which brokecargo tauri buildimmediately with a version-mismatch error.
Added
scripts/launch-alice.ps1/launch-bob.ps1: Windows launcher scripts that pre-setVAULTEX_DATA_DIRandVAULTEX_SERVER_URLso two desktop clients can run side-by-side against a remote server without UI configuration. Accept-Fresh(wipe vault),-Server <url>, and-Exe <path>flags.
[0.10.0] - 2026-05-29
Added
Tester UX & Honest Connectivity (#136–#141)
- Persistent dev-server scripts (#136):
scripts/dev-server-up.{sh,ps1}bring up the persistent Postgres + Redis stack and run the server natively;dev-server-down.{sh,ps1}tears it down (--wipeclears volumes). Persistent mode is now the documented default; demo mode prints a loud startup warning and is scoped to unit tests. - Reset Local Data (#137): Settings → Danger Zone control that securely wipes the local SQLCipher database and zeroizes in-memory key material, then restarts on a fresh-account screen. Gated behind a typed
RESETconfirmation plus a backend confirm token. - Session-establish status surfacing (#138): adding a contact now reports a typed
sessionStatusand shows an actionable banner (account-not-found / server-unreachable / prekey-bundle-unavailable) instead of silently falling back to local-only mode. Re-adding a known contact is idempotent (dedupe on identity key); adding your own key is rejected. GET /api/v1/ping(#139): unauthenticated capability probe returning{ service, version, min_client_version, capabilities }so clients can confirm a URL is a real VAULTEX server before the WebSocket handshake.- Test Connection (#140): a Settings → Server Connection diagnostic that probes ping + account registration and reports an honest verdict (unreachable / not-a-VAULTEX-server / reachable / account-registered / account-not-registered) with actionable copy, replacing the optimistic "connected".
- Opt-in user discovery (#141): default-off Settings → Privacy toggle to be findable by display name on a server, and a Browse Server dialog to find and add discoverable peers after confirming their safety-number fingerprint. Server adds authenticated set/read-back/list endpoints (rate-limited, suspended-filtered, self-excluded, LIKE-escaped) across both storage backends. End-to-end encryption is unchanged; opting out purges the stored metadata.
Changed
- The server now applies SQLx migrations on startup (Postgres backend); discovery columns ship in both the migration and
infrastructure/postgres/init.sql. http_client::auth_getsigns over the request path without the query string, matching the server's auth middleware (enables authenticated GETs with query parameters).
Fixed
- Secure local-data wipe (#137):
wipe_all_datanow scrubs the database file (secure_delete,VACUUM, WAL truncate) rather than only deleting rows — wiped identity keys and messages are no longer recoverable from the freelist/WAL given the non-secret bootstrap DB key. Also hardens the duress-PIN wipe and zeroes the retained rotation-grace prekey.
[0.9.0] - 2026-05-24
Added
Android P2P Transport (#94–#98)
- Full P2P transport manager with Bluetooth LE, WiFi Direct, and LAN/Bonjour backends, offline message queue, and a manual peer-connect path. Brings Android to parity with the desktop transport stack.
- Maestro UI automation framework (#93) for cross-device acceptance testing.
- QR code contact exchange (#91): generate and scan invite codes; QR scanner accepts
vaultex://invite links and pre-fills the add-contact dialog.
Desktop UI
- QR code display and invite-link generation on the desktop client (matches Android UX).
Server / Infrastructure
- Server containerization (
infrastructure/docker-compose.prod.yml): production-grade compose stack with Caddy TLS termination, Watchtower image auto-rollout, Postgres + Redis volumes. - Cloud deploy scaffolding (
.gitlab-ci.yml):build-server-dockerpushes to GitLab Container Registry; manualdeploy-serverjob SSH-deploys to a Hetzner-class host. - Linux bundle build script (
scripts/build-linux-bundles.sh, #131): one-command.deb+.AppImageproduction fromapps/desktop/src-tauri, with prereq checks and artifact verification.
Documentation
docs/distribution/linux-tester-setup.md: install/run instructions for non-dev Linux testers (.deband.AppImagepaths, data locations, troubleshooting).docs/windows-build.md: native Windows x64 build recipe (MSVC, libsodium, SQLCipher) producing the NSIS installer.infrastructure/DEPLOYMENT.md: prod deploy how-to (host setup, secrets, Watchtower, manual deploy, rollback, backups).docs/operations/cloud-hosting-and-deploy-plan.md: planning doc covering host selection (Hetzner CX22 recommended), co-hosting the marketing site, release-driven deployment automation, and the GitHub-vs-GitLab path for public security review.docs/testing/peer-review-report.md,docs/testing/peer-review-report-p2p-transport.md,docs/testing/mobile-acceptance-test-report.md: multi-expert peer-review reports for the iOS, P2P transport, and Android-acceptance sweeps.
Fixed
iOS Peer Review (bugfix/ios-peer-review, !43)
- FFI pointer ABI (iOS + Android):
ffi_identity_sign/ffi_identity_verifynow take*const u8/*mut u8instead of*const [u8; 32]/*const [u8; 64]. cbindgen previously lowered the array-pointer form into opaque Swift tuple types, forcing callers into unsafeassumingMemoryBoundgymnastics; the Android JNA bindings were already usingByteArrayand were silently mismatched. - iOS
VaultexCrypto.sign/verify: rewritten to pass arrays directly, removing ~20 lines of tuple-binding scaffolding per call site. - iOS
PersistenceController: Core Data load failure nowfatalErrors instead of silentlyprinting. - iOS
TransportTypeenum:.wifi→.wifiDirect(rawValue"WIFI_DIRECT") to match Rust/Android wire format. - iOS
project.yml: removed staleLIBRARY_SEARCH_PATHS/OTHER_LDFLAGS -lvaultex_ffi; replaced deprecatedUILaunchStoryboardNamewithUILaunchScreen.
Android
- Passphrase verification: store + verify Argon2id hash on login (matches desktop semantics).
- P2P transport stability: LAN server race condition, WiFi Direct discovery port + receiver issues, LAN data-port exchange + permissions, QR scanner accepting invite links.
- Version-string source: read from BuildConfig instead of hardcoded string (#92).
Website
- CSP unblocked Google Fonts + inline hydration scripts in
apps/website/nginx.conf— interactive widgets (demo chat, comparison chart) now hydrate when served from the container. - Added
apps/website/docker-compose.ymlfor one-command local serving.
CI / Lint
- Unblock CI gates that were red on develop (rust-lint + frontend-lint).
- Prettier write across UI to unblock frontend-lint.
Changed
- Tauri desktop crate:
cargo fmtwhitespace pass acrosssrc-tauri(no behavior change).
[0.8.0] - 2026-03-23
Fixed
- FFI pointer ABI (iOS + Android):
ffi_identity_sign/ffi_identity_verifynow take*const u8/*mut u8instead of*const [u8; 32]/*const [u8; 64]. cbindgen lowered the array-pointer form into opaque Swift tuple types ((UInt8, UInt8, ... ×32)), forcing callers into unsafeassumingMemoryBoundgymnastics that were unidiomatic and fragile. The Android JNA bindings were already usingByteArray(which marshals to*const u8), so the old signature was actively mismatched there. The new signature is the same ABI both callers naturally produce. - iOS
VaultexCrypto.sign/verify: rewritten to pass arrays directly (Swift auto-converts[UInt8]toUnsafePointer<UInt8>), removing ~20 lines of tuple-binding scaffolding per call site. - iOS
PersistenceController: Core Data load failure now callsfatalErrorinstead of silentlyprinting. A broken persistence stack cannot be recovered from mid-run — swallowing the error left the app in an undefined state. Will become user-visible recovery when SQLCipher lands (tracked for follow-up). - iOS
TransportTypeenum:.wifi(rawValue"WIFI") renamed to.wifiDirect(rawValue"WIFI_DIRECT") to match the RustTransportType::WifiDirectand AndroidTransportType.WIFI_DIRECTwire format. Previously an iOS peer serializing aPeerInfowould emit"WIFI"which no other client recognized. - iOS
PeerDiscoveryView: updatedswitchto use the renamed.wifiDirectcase (was a compile error after the rename). - iOS
project.yml: dropped staleLIBRARY_SEARCH_PATHS/OTHER_LDFLAGS -lvaultex_ffi(the FFI is now shipped asVaultexFFI.xcframeworkand linked viadependencies:, so the flat-file search path was dead config). Replaced deprecatedUILaunchStoryboardName: LaunchScreenwith modernUILaunchScreen: {}(required for iOS 14+ apps built with Xcode 12+).
Regenerated
apps/ios/VaultexApp/Crypto/include/vaultex_ffi.hre-ran through cbindgen (apps/ios/scripts/build-ffi.sh) after the Rust signature change; now byte-identical tocbindgen --config cbindgen.toml --crate vaultex-ffi.
[0.8.0] - 2026-03-23
Security
Auth Bug Fix (#90)
- Passphrase verification: Login now verifies passphrase against Argon2id hash stored during registration. Previously any passphrase >= 8 chars could access the app.
- Media encryption: Replaced XOR placeholder cipher with AES-256-GCM in Android MediaManager
Security Hardening (#39)
- Screenshot prevention: FLAG_SECURE on Android prevents screenshots and screen recording
- Clipboard auto-clear: Copied sensitive data auto-clears after 30 seconds
- Root detection: Warns user of rooted device, debugger, or emulator (non-blocking)
- Secure storage: Android Keystore wrapper for encrypted preferences
Added
Phase 7: Android Mobile App (#75-#89)
- Android scaffold (#75): Gradle project with Jetpack Compose, Material3 dark theme
- FFI bindings (#76): JNA wrappers for all 20 Rust FFI functions — identity, X3DH, Double Ratchet, sealed sender, file encryption, safety numbers
- SQLCipher database (#77): Room + SQLCipher encrypted storage with 14 entities
- Auth screens (#78): Login, register, PIN setup, seed phrase display
- Contact management (#79): Add, search, verify, archive, block contacts
- Messaging (#80): Send/receive with status tracking, read receipts, typing indicators, reactions, editing, search
- Chat UI (#81): Message bubbles with status icons, TTL indicators, typing dots, input bar
- Network layer (#82): WebSocket + HTTP fallback, Ed25519 auth, exponential backoff reconnect
- Group messaging (#83): Group entities, create/list groups, fan-out encryption
- Media transfer (#84): Encrypted upload/download with AES-256-GCM, image thumbnails
- Voice/video calls (#85): Call history, active call UI with timer/mute, signaling
- Export/import (#86): Encrypted conversation backup with PBKDF2 + AES-256-GCM
- Settings (#87): PIN, duress PIN, server URL, identity display, biometric toggle
- CI/CD (#88): Android lint, test, debug build, release build pipeline
- E2E tests (#89): Compose testing framework for auth and navigation flows
- Biometric auth (#37): AndroidX Biometric fingerprint/face unlock
- Push notifications (#38): Content-free, sender-only, and preview notification modes
- Beta distribution (#40): Release signing config via environment variables
FFI Completion (#33)
- All 20 Rust FFI functions now have JNA bindings and Kotlin wrappers
- JNA Structure mappings for complex return types (FfiByteBuffer, FfiEncryptResult, etc.)
- computeSafetyNumber() fully wired (no longer placeholder)
Tech Debt (#63, #64)
- WebSocket rate limiting (100 msg/10s per connection)
- Message ID collision fix (crypto.randomUUID vs Date.now)
- Export chunked processing (1000 messages at a time)
- Timestamp format standardized to ISO 8601
- Call history click confirmation
- Tor TLS policy documentation
Acceptance Tests
- 31 automated server-level acceptance tests covering 7 phases
- 130+ Android acceptance tests (auth, contacts, messaging, network, crypto)
Changed
- Desktop app version bumped to 0.8.0
- Android app versionCode 8, versionName 0.8.0
- All Cargo workspace crates version 0.8.0
Test Coverage
- 694 total tests (337 Rust + 79 frontend + 278 Android), 0 failures
- All 90 GitLab issues closed
Peer Review Fixes (bugfix/peer-review-fixes)
- Targeted relay: Read receipt and typing indicator WebSocket relay now sends only to the intended recipient, not broadcast
- Payload size limits: Enforced maximum payload sizes on incoming WebSocket and REST messages to prevent abuse
- FTS5 query sanitization: Full-text search queries are sanitized to prevent FTS5 syntax injection
- Export key zeroing: Chat export encryption key material is zeroized after use via
zeroizecrate - Duress wipe completion: Duress PIN wipe now clears all in-memory state (stores, sessions, keys) in addition to database
- PIN timing equalization: PIN verification uses constant-time Argon2id comparison to prevent timing side-channels
- Lock state enforcement: App lock state is enforced on all Tauri commands, not just the frontend gate
- Tor transport hardening: SOCKS5 feature-gated, Tor client reuse to avoid circuit churn, connection timeouts, strict .onion address validation
Added
Phase 1c: Tor Transport (#12)
TorTransportinvaultex-transport— routes messages through Tor SOCKS5 proxy for IP-level anonymity.onionhidden service address support withonion_onlymodeTransportType::Torvariant with Tor priority inTransportManager(LocalNet > WifiDirect > Bluetooth > Tor > Server)- HTTP polling through Tor for message retrieval
- 10 unit tests for Tor transport configuration and connectivity
Phase 4: Enhancements (#45-#52)
- Message Search (#45): FTS5 full-text search on decrypted messages via SQLCipher,
search_messagesTauri command,SearchBarandSearchResultsReact components,searchStoreZustand store - Read Receipts & Typing (#46):
ReadReceipt,TypingStart/StopWebSocket protocol extensions,ReadReceiptandTypingIndicatorUI components, ephemeral typing relay on server - Reactions & Editing (#47): Emoji reactions on messages (
message_reactionstable), message editing with 5-minute window (message_editstable),add_reaction,remove_reaction,edit_messageTauri commands - Chat Export/Import (#48): Encrypted
.vaultex-exportarchives (AES-256-GCM + Argon2id KDF),export_conversationandimport_conversationTauri commands - App Lock (#49): Configurable inactivity timeout (1min–1hr), PIN re-entry on lock,
set_lock_timeout,lock_app,unlock_appcommands - Archive & Block (#50): Archive/unarchive conversations (local-only), block contacts (silently drop messages),
blocked_contactstable - Notifications (#51):
NotificationSettingscomponent with content-free/sender/preview modes, DND schedule, per-conversation mute,notificationStore - Unread Badges (#52):
UnreadBadgecomponent, unread count tracking inmessagesStore, auto mark-as-read on conversation open
Phase 5: Voice Chat (#53-#57)
- Call Signaling (#53):
CallOffer,CallAnswer,IceCandidate,CallHangup,CallReject,CallBusyWebSocket protocol types with E2E encrypted SDP/ICE payloads. Server relays without storing. - WebRTC Types (#54): SRTP key derivation design from Double Ratchet via HKDF-SHA256
- Call State Machine (#55):
callStorewith Idle→Offering→Ringing→Connecting→Connected→Ended states, ring/ICE timeouts - Voice Call UI (#56):
IncomingCallOverlay,ActiveCallViewwith mute/hangup controls, duration timer, quality indicator - Call History (#57):
call_historySQLCipher table,CallHistoryListcomponent with direction/status/duration, missed call tracking
Phase 6: Video Chat (#58-#62)
- Video Call UI (#59):
VideoCallViewwith remote video + self-view PiP, auto-hiding controls, full-screen mode - Group Video (#62):
GroupCallGridwith CSS grid for 2-4 participants, active speaker detection, speaker/grid view toggle - Screen Sharing (#60):
ScreenShareControlswith source picker and stop-sharing overlay - Quality Panel (#61):
CallQualityPanelwith RTT/loss/jitter/codec stats, expandable panel with signal bars
CI/CD
- Build stage in GitLab CI: produces
.debartifact for desktop app (downloadable from pipeline) - Server Docker image build stage (main/tags only)
Desktop App (apps/desktop/)
- PIN security: Unlock PIN with Argon2id hashing (32 MiB, 3 iterations, 4 lanes), set during registration or in settings
- Duress PIN: Secondary PIN that silently wipes all data (database + in-memory state) while returning success to the attacker
- PIN gate on login screen — if PIN is set, prompts after passphrase authentication
- PIN and Duress PIN management in Settings screen with set/change/confirmation flows
- Settings screen with configurable server URL, connection status, identity info, key management, and logout
- Message delivery status indicators — server sends
Deliveredreceipt withrecipient_id, client updates message status in real-time message-deliveredTauri event for frontend delivery receipt handling
Tauri Backend (apps/desktop/src-tauri/)
app_settingstable for PIN/duress PIN storage (hash + salt)db::pinmodule — hash, verify (constant-time), store, load, wipe functions- Tauri commands:
get_pin_status,set_pin,verify_pin,set_duress_pin - 12 Rust PIN unit tests + 5 command-level tests
- State persistence: Identity, contacts, sessions, messages, and prekeys now persisted to SQLCipher database across app restarts
login()restores full state from database: identity keypair, contacts, Double Ratchet sessions, messages, signed/one-time prekeysregister()persists identity and prekeys to database immediately after generationadd_contact(),remove_contact(),verify_contact()persist to databasesend_message()andreceive_message()persist messages and updated ratchet session state to databasemark_message_read()persists read status to database- WebSocket handler persists received messages and session state via DB connection
Crypto (crates/vaultex-crypto/)
RatchetState::to_bytes()/from_bytes()for session serialization and database persistenceSignedPreKey::from_bytes()andOneTimePreKey::from_bytes()for restoring prekeys from databaseIdentityKeyPair::secret_key_bytes()for secure persistence to SQLCipherCryptoError::SerializationErrorvariant for serialization failures
Server (crates/vaultex-server/)
recipient_idfield added toDeliveredWebSocket protocol message for client-side delivery tracking
Changed
Desktop App (apps/desktop/)
- Replaced all hardcoded
http://localhost:8080URLs with configurablenetworkStore.serverUrl - Settings gear button in sidebar now navigates to full settings view (replaced inline dropdown)
Previously Added
Crypto (crates/vaultex-crypto/)
- Ed25519 identity keypair generation and signing (
identity.rs) - X3DH key exchange — initiator and acceptor sides (
x3dh.rs) - Double Ratchet with AES-GCM (actually XChaCha20-Poly1305 via libsodium) (
double_ratchet.rs,aes_gcm.rs) - Sealed sender envelope — hides sender identity from server (
sealed_sender.rs) - Client-side auth module — Ed25519 challenge-response over method:path:timestamp:body_hash (
auth.rs) - MessagePayload with optional TTL for self-destructing messages (
message_payload.rs) - Safety number generation — SHA-256 of sorted identity keys, 12 groups of 5 digits (
safety_number.rs) - Key rotation helpers —
needs_rotation(), rotation intervals, grace periods (prekeys.rs) - Power-of-2 bucket padding (256–65536 bytes) with dummy traffic generation (
padding.rs) - Group messaging primitives — GroupId, GroupInfo, member management (
group.rs) - XChaCha20-Poly1305 per-file media encryption with random key (
media.rs) - Security utilities —
constant_time_eq,secure_random_bytes,wipe_memory(security.rs) #[must_use]annotations on all Result-returning crypto functionsdebug_assert!for non-zero key generation in identity and prekeyssodiumoxide::utils::memzeroin Drop impls to prevent compiler elision- Skipped message key caching per Signal Double Ratchet spec with replay protection and MAX_SKIP bound
- 6 end-to-end integration tests covering full message flow, sealed sender, out-of-order delivery, multi-party isolation, forward secrecy, and auth signing
- 262+ unit tests across all Rust crates (crypto, server, transport, Tauri backend)
Server (crates/vaultex-server/)
- Axum REST API with PostgreSQL 16 and Redis 7 backends
- In-memory Storage backend for demo mode (no external deps required) — activate with
VAULTEX_DEMO=1 - Account registration:
POST /api/v1/accounts/register - Account lookup by identity key:
GET /api/v1/accounts/by-key/:hex - Prekey bundle fetch:
GET /api/v1/accounts/:id/prekey_bundle - Signed prekey upload and rotation
- One-time prekey storage and consumption
- Message send:
POST /api/v1/messages/send - Sealed sender send:
POST /api/v1/messages/sealed(no auth required) - Media upload/download with 100 MiB limit
- Group CRUD API (6 endpoints)
- Ed25519 challenge-response auth middleware with timestamp freshness validation
- Rate limiting middleware
- WebSocket handler with Ed25519 auth, message routing (online=immediate, offline=queued), ack-based queue cleanup, ping/pong keepalive, and JSON wire protocol
- Crypto verification module for Ed25519 signature and signed prekey validation
- Multi-stage production Dockerfile with non-root user and healthcheck
- 55+ unit tests across API, middleware, WebSocket, and crypto modules
Desktop App (apps/desktop/)
- Tauri 2.x + React 18 + TypeScript desktop shell
- Vite build with strict TypeScript and Vitest test framework
- Tailwind dark theme with custom
vx-*color tokens - 10 React components: LoginScreen, RegisterScreen, Sidebar, MainPanel, ContactList, ContactItem, ChatHeader, MessageBubble, MessageInput, SafetyNumberDialog, KeyStatusIndicator
- 5 Zustand state stores: authStore, contactsStore, messagesStore, uiStore, networkStore, keyStatusStore, groupsStore
- Register command generates real Ed25519 identity + prekeys and uploads to server
- Login command restores identity from passphrase
- AppState with Mutex-wrapped identity, sessions, contacts, messages; key material zeroized on logout
- SQLCipher local database with Argon2id key derivation (accounts, contacts, messages, sessions, prekeys tables)
- HTTP client with Ed25519-signed requests for authenticated API calls
- WebSocket client with auto-reconnect (exponential backoff) and Tauri event emission
add_contactfetches prekey bundle from server and initiates X3DH automaticallysend_messageencrypts via Double Ratchet, wraps in sealed sender, sends via WebSocket or HTTP fallbackreceive_messagedecrypts incoming messages and auto-creates receiver X3DH sessions from init datainitiate_sessionperforms full X3DH key exchange from prekey bundle- Key rotation commands: check, rotate signed prekey, replenish one-time prekeys, cleanup expired
- Safety number and contact verification commands
- Self-destruct message support: TTL selector, countdown timer, mark-as-read triggers, periodic cleanup
- Media commands for encrypted file upload and download
- Group messaging commands
- Auto-connect WebSocket and set up event listeners after authentication
- Sidebar: identity key display with copy button, server connect button, add-contact form, search
- Bundle config for deb/appimage/msi/dmg with icon generation
Infrastructure (infrastructure/)
- Docker Compose for PostgreSQL, Redis, Nginx
- Nginx reverse proxy configuration
- PostgreSQL init scripts
- Dev scripts:
dev-setup.sh,db-reset.sh,test-runner.sh,demo.sh
Documentation
VAULTEX_DESIGN.md— Full design document (architecture, crypto protocol, API, schemas, roadmap)README.md— Project overview with architecture, security model, and quick startCONTRIBUTING.md— Developer onboarding and contribution guidedocs/team/roles.md— Team role definitionsdocs/team/processes.md— Sprint ceremonies, Git workflow, code review, release process, DoDdocs/team/automated-review.md— CI/CD and GitLab automation setupdocs/adr/ADR-0001— Initial technology choicesdocs/adr/ADR-0002— P2P off-grid messaging transport abstraction (Phase 3)docs/security/— Threat model, crypto inventory, audit checklist, dependency auditdocs/preview/app-mockup.html— Customer-facing UI mockup- GitLab issue templates (bug report, feature request, security vulnerability)
- MR template with security checklist
- CODEOWNERS for security-critical paths
CI/CD
- GitLab CI pipeline with lint, test, audit, coverage stages
- System deps (libsodium, libssl, libpq) in CI images
- Strict clippy for lib, standard for tests
- ESLint and Prettier configs for frontend
Fixed
- Double Ratchet skipped message keys now cached per Signal spec instead of discarded
ON CONFLICTclause instore_signed_prekeycorrected to match composite PK(account_id, prekey_id)queued_messagesrenamed tomessage_queueindelete_accountSignature::as_ref()used for sodiumoxide compatibility- Tauri config: removed duplicate
identifierin bundle, removed invalidtitleprop, fixed icon refs - Bidirectional X3DH sessions: receiver auto-creates session from X3DH init data in first message
- Associated data mismatch in
receive_message— now uses own account_id to match sender's encryption - "Rotate Now" button forces rotation instead of skipping when keys are fresh
- Contact selection uses proper Zustand primitive selectors (prevents re-render loops)
- Server URL default corrected to 8080 (matching server default)
- WebSocket event listeners wired up in App.tsx so incoming messages are received and decrypted
- Auto-connect WebSocket after authentication
- Tauri compile errors: private field access on IdentityKeyPair, missing
use tauri::Manager, borrow lifetime issues, non-exhaustive match on SealedMessage variant - All clippy warnings resolved (abs_diff, unused mut, fmt, assertions_on_constants)
- Conflicting X3DH sessions: Both parties independently initiating X3DH in
add_contactcreated incompatible sender sessions. Fixed with lazy session creation — X3DH shared secret is computed inadd_contactbut the ratchet session is only created insend_messagewhen the first message is sent. The receiver creates the matching session from X3DH init data in the received message. This ensures both parties share the same session. - Outgoing WebSocket channel dropped:
let (tx, _rx)immediately dropped the receiver half, silently losing all outgoing messages. Fixed by passingrxinto the relay loop. - Duplicate WebSocket connections: Multiple
connect()calls spawned competing tasks causing rapid connect/disconnect storms. Fixed with a guard that returns early if already connected. - Tauri event system bypass: Frontend
listen("message-received")via dynamic import never fired even thoughapp.emit()returnedOk(()). Fixed by decrypting messages directly in the Rust WebSocket handler (handle_server_messagecallsreceive_message_inner) and emitting amessage-decryptedevent with plaintext. Added 3-second polling fallback viapollAllMessagesfor guaranteed delivery. - X3DH init data ignored on existing sessions:
receive_message_innerskipped processing X3DH init data if any session existed, preventing the receiver from creating the correct receiver session. Now always processes incoming X3DH init data, replacing any incompatible sender session. - Missing tracing subscriber: All
tracing::info!calls silently dropped. Addedtracing_subscriber::fmt().init()inlib.rsandprintln!diagnostics at critical points. - Pending X3DH not cleared on logout: Shared secrets in
pending_x3dh_initwere not zeroized during logout. Now cleared alongside other key material. - Extracted
receive_message_inneras a public function callable from both the Tauri command and the WebSocket handler