Pairing a phone with your desktop is supposed to be the boring part of building a mobile companion app — scan a code, see a checkmark, move on. Ours wasn't, because doing it properly meant solving two distinct hard problems: making the cryptography genuinely correct on a mobile JavaScript runtime that doesn't hand you secure randomness or reliable type-checking for free, and accepting that a user's phone and desktop will routinely run different app versions at the same time, simply because app stores don't review updates on our schedule. Neither problem was optional, and neither could be solved by assuming it away.
Why the Secure Channel Starts at Pairing, Not the Network
The Influxx mobile app pairs with the desktop app over a dedicated real-time connection, so you can check on a long-running agent, glance at your notes, or nudge a CLI from your phone without being at your desk. That connection carries session data — the kind of thing you don't want readable by anyone sitting between the two devices. So we encrypt it end-to-end, using Curve25519 for the key exchange (elliptic-curve Diffie-Hellman) and XSalsa20-Poly1305 for authenticated encryption of everything that follows — neither invented for this app, but well-studied, widely deployed constructions chosen because pairing is not the place to be clever.
The part worth dwelling on is where the encryption boundary actually sits. It would have been simpler to treat encryption as something the network handles — assume a VPN, a trusted Wi-Fi network, or that nobody else on your local segment is a problem. We didn't want any of that to be a load-bearing assumption. Instead, the key exchange happens as part of pairing itself: the moment your phone and desktop agree to talk, they've already negotiated a shared secret that never leaves the two devices. By the time any session data moves, the channel is already private — not because the network happens to be trustworthy, but because pairing made trust in the network irrelevant.
"We didn't want the privacy of that connection to depend on whether you trust your home router, your office network, or the coffee shop you're sitting in. The key exchange happens during pairing itself, so the channel is already secure before a single byte of session data crosses the network. Trusting the network was never part of the design — that was the whole point."
— Ryan Chase, Security Engineer at ETAPX
That distinction matters more than it looks. An encrypted-transport approach is only as private as the assumptions around it — who controls the network, who else can reach it, whether a VPN is actually active. Encrypting at pairing removes the network from the trust calculation entirely. Your phone and desktop don't need to agree on what network to trust. They just need each other.
The Cryptography Is Only as Strong as Its Randomness
Curve25519 key exchange is only as secure as the private keys feeding it, and private keys are only as unpredictable as the random number generator that produces them. This is where building the mobile side of pairing got genuinely difficult, because the JavaScript engine React Native runs on doesn't give you a cryptographically secure random number generator by default on the versions our mobile app targets.
A Runtime That Looks Fine and Isn't
This is the kind of gap that doesn't announce itself. An app built on a weak or predictable random source still pairs successfully, still shows an encrypted connection, still works in every way a user or a casual code review would notice. The keys it generates are simply weaker than they appear — derived from less entropy than the cryptography assumes, and more susceptible to being guessed than a correct implementation would allow. Nothing about the experience would tip anyone off. That combination, fully functional but quietly weaker, is exactly the kind of gap you want to find on your own terms rather than discover later.
The fix was to stop trusting the runtime's default behavior and be explicit instead. We wired the platform's own native secure random number generator — the one iOS and Android already provide at the operating-system level — directly into the encryption library used for pairing, through a small native crypto library that bridges JavaScript to that platform primitive. The key exchange now draws its randomness from the same trusted source a fully native app would use, not whatever the JavaScript engine exposes by default.
When a Byte Array Fails Its Own Type Check
The second wrinkle was stranger. Hermes, the JavaScript engine React Native runs on, has objects that behave exactly like a standard Uint8Array in every practical sense — indexable, iterable, correct byte length, accepted anywhere binary data is expected — while still failing a strict instanceof Uint8Array check elsewhere in the code. Uint8Array is the standard JavaScript representation of raw binary data, and cryptographic code, including the encryption library pairing depends on, routinely checks that exact type before trusting a value as key material or ciphertext.
That's a hard bug to reason about, because both sides of the mismatch are technically correct. The object really does behave like a Uint8Array at runtime. The check really is checking for one. They just don't agree with each other in every situation the runtime produces, and a strict check has no way to know the thing in front of it would pass every functional test except the one it's actually running.
"You can spend a long time trying to trace exactly which code path produces the object that fails the check, and on this runtime that isn't always a productive use of time. We stopped trying to guarantee it upstream and instead guaranteed it at the boundary: every buffer that matters gets wrapped in a fresh
— Nina Petrova, Head of Mobile at ETAPXnew Uint8Array(...)at the point where it's about to be checked or used. It's not elegant, but it's correct everywhere, which is the property we actually needed."
Both fixes share a theme. React Native gives you a JavaScript environment that looks complete, and mostly is, but the gaps that remain sit in the two places a pairing protocol depends on most: genuinely random numbers, and a binary buffer being recognized as one everywhere it's checked. Neither shows up as a crash. Both had to be found deliberately rather than waited out.
Why Your Phone and Desktop Are (Usually) Running Different Versions
The second half of building pairing had nothing to do with cryptography and everything to do with how software reaches people's devices. Influxx desktop ships updates on our own schedule. The mobile app ships through the iOS App Store and Google Play, both of which review every release before it goes live — a process that can add a day or more of lag on top of when we submit a build.
The consequence is simple and completely ordinary: at any given moment, a meaningful share of our users have a desktop app slightly ahead of their phone's app. This isn't a rare misconfiguration — it's the normal state of a product shipping on two release cadences it doesn't fully control. Any pairing design that assumed both sides always ran matching versions was going to be wrong on a regular basis, for reasons entirely outside our own release process.
Teaching Both Sides to Say Their Version Out Loud
So we designed for the mismatch instead of hoping around it. Desktop and mobile each report their own protocol version as part of every connection status check — not just once, at initial pairing, but on an ongoing basis for the life of the connection.
- Every connection check carries a version number: not just at initial pairing, so a mismatch is caught whether it's present from the first handshake or introduced later by an update on either side.
- Compatible versions stay invisible: the check passes silently and pairing behaves exactly as it would if the versions matched exactly.
- Incompatible versions stop the connection outright: pairing halts and shows an explicit explanation instead of proceeding with a protocol either side only partially understands.
That last point is the entire design decision: when the versions are genuinely incompatible, pairing stops and shows a clear screen explaining that the two can't work together yet, rather than letting a mismatched phone and desktop talk anyway and quietly misbehave — dropped messages, malformed state, a connection that looks alive but isn't.
"I updated Influxx on my laptop the morning it came out, like I always do, and my phone hadn't caught up yet because Apple was still reviewing it. Instead of the app just acting weird, it told me flat out: these versions don't match yet, here's what to do. That's such a small thing, but it's the difference between trusting a tool and debugging it."
— Marcus Ihejirika, backend engineer and Influxx user
A silent mismatch is worse than an obvious one, because a silent mismatch costs you the time it takes to even realize something is wrong before you can start fixing it. An obvious one costs you nothing but a glance at the screen.
Two Codebases, One Constant, Kept Honest by Hand
There's a detail here we don't love, but it's the honest tradeoff behind the version check: the protocol version constants desktop and mobile compare against each other are maintained by hand, separately, in two different places in the codebase — one living with the desktop code, one with the mobile code. Ideally, both sides would import that number from a single shared source, making drift structurally impossible.
We don't have that today, because the mobile app's build tooling — Metro, the bundler React Native relies on — doesn't cleanly resolve shared code imported from outside its own dedicated mobile folder. Reaching across that boundary isn't a supported pattern the way it would be in a more conventional monorepo. Rather than fight the bundler over a single integer, we accepted the manual duplication.
That's a reasonable trade only because of how infrequently it matters: these constants change less than once a quarter, tied to actual protocol changes rather than routine releases. A step that happens by hand a few times a year, backed by a deliberate check on both sides, is a very different risk than a shared value silently drifting apart on every build. We'd still rather the import resolve cleanly. Until it does, doing it by hand and doing it deliberately is the safer option.
Frequently Asked Questions
Does end-to-end encryption mean ETAPX can't see what's in my paired session?
Yes. The key exchange happens directly between your phone and your desktop during pairing, and the resulting keys never leave those two devices. The connection is encrypted before any session data moves across it, so there's no version of this design where a server in the middle holds the keys or could decrypt the traffic.
What actually happens if my mobile app and desktop app are on different versions?
Most of the time, nothing you'd notice — small version differences are compatible, and the connection status check that runs continuously in the background confirms that silently. It's only when the two versions are genuinely incompatible that pairing stops and shows you an explicit screen explaining the mismatch, instead of connecting anyway and behaving unpredictably.
Why doesn't Influxx just require both apps to be the same version before pairing?
Because that would turn version skew into a routine failure instead of a routine non-issue. App store review means your phone's update can lag your desktop's by a day or more as a matter of course, not a rare exception. An exact-match requirement would mean a large share of pairing attempts failing for a reason that has nothing to do with actual incompatibility.
Is Curve25519 with XSalsa20-Poly1305 something ETAPX built specifically for Influxx?
No, and that's deliberate. Both are well-established cryptographic primitives with a long track record outside this app — Curve25519 for elliptic-curve Diffie-Hellman key exchange, XSalsa20-Poly1305 for authenticated encryption of the data that follows. We chose widely reviewed, widely deployed constructions over anything custom, because pairing security isn't a place to introduce novelty.
Why was React Native's default randomness a problem if the app still worked and still showed as encrypted?
Because working and showing as encrypted isn't the same as being as strong as intended. A secure key exchange depends on the private keys being genuinely unpredictable. If the random number generator behind those keys is weak, pairing still completes and the interface still looks correct, but the keys are more predictable than the cryptography assumes — a gap you wouldn't see from the outside, which is exactly why we went looking for it rather than assuming the runtime handled it correctly.
Why are the version-check constants maintained separately on desktop and mobile instead of shared?
Because the mobile app's build tooling can't cleanly import shared code from outside its own project folder, so a single source of truth isn't currently practical without a larger restructuring. We accepted manual duplication in two places as the trade-off, since these constants change less than once a quarter and each change is a deliberate, checked step rather than routine churn.
None of this makes for a flashy feature — nobody opens Influxx hoping to think about random number generators or bundler resolution rules. But pairing your phone to your desktop is the one moment where cryptography, mobile runtime quirks, and release-cadence reality all show up at once, and getting each of them quietly right is what makes the rest of the experience as boring as it's supposed to be.

