Inspiration

Every mainstream messenger tells you your messages are private while keeping something readable on its own servers. Maybe not the text, but at least who you talked to and when. I wanted to see how far the opposite bet goes: not "we promise not to look" but "we deleted the column we would have looked at."

There's a second thread tied to the hackathon. Shipaton rewards a real monetization story, and a privacy app that puts safety behind a paywall isn't a privacy app. So the revenue line had to be cosmetics and nothing else. That constraint ended up shaping the product instead of being bolted on at the end to satisfy a prize category.

What it does

Nearside is an end-to-end encrypted messenger. Android is the shipping build; there's a Linux desktop shell for convenience, and an iOS project that has been configured for months and has never met a Mac. One-to-one chats, group rooms, photos, video, voice notes, voice and video calls, stickers, disappearing messages. English, Spanish, German, Russian, Chinese and more are supported translations. The keys stay on the phone and the server holds nothing readable.

Twelve recovery words generate a seed that lives in the Android Keystore and never leaves the device. Three keys come off that one seed: one for one-to-one messages, one for signing, one for a private self-chat vault. There is no reset path, which is the honest consequence of a server that holds nothing.

There is also no user directory. You add someone by scanning their QR code or reading eight characters out loud. The QR carries their public key along with the code, so adding them and verifying them are the same step. No separate "compare your safety numbers" screen that every app offers and nobody opens. If a contact's key changes later, the composer locks until you look at why.

Group rooms use one symmetric key sealed separately to each member, with an Ed25519 signature checked before anything is decrypted. Every member holds the room key, so the signature is the only thing that establishes who actually wrote a message.

The server holds ciphertext. Migration 0023 dropped the message-content column and the server-side search function that read it, so search and conversation previews run against a local SQLite mirror of what your own device has decrypted. Push notifications carry a sender and never a message. That isn't a policy decision; after 0023 there is no body left to leak.

Pictures get the same treatment as text. Attachments are sealed with a random per-file key, and photos are stripped of their location data before they go out, because encryption doesn't hide GPS coordinates from the person on the other end. Chat backgrounds used to be the one image that went up in the clear; migration 0039 sealed those too, under the owner's vault key, since a wallpaper is chosen by one person and shown to nobody. The app is also excluded from Android's system backup, because the pinned attachments and the decrypted local mirror were being copied to Google Drive in plaintext by an app whose whole claim is that the server holds nothing.

Two features are built on that premise rather than around it. Sealed exchange lets two people answer the same question without either one reading the other's answer first: you commit your own answer when you ask, and both open at once when both exist. The withholding is a row-level policy in Postgres, not a check in the app, because this repository is public and a check in the app is one anyone can delete. "In this conversation" lists the dates somebody named and the links somebody sent, read from the decrypted copy sitting on the phone, and every row jumps back to the message it came from so you can check it yourself.

Calls are peer-to-peer. The signalling is sealed too, SDP and ICE candidates alike, because a candidate line carries your LAN address and your public IP. It travels over a broadcast channel that persists nothing, so there is no calls table and no record that a call happened. A ring can be answered from the lock screen of a phone whose app the system has already killed.

The revenue line is six cosmetic theme packs sold once through RevenueCat, three themes free forever, and donation tiers that buy nothing at all. Nothing functional sits behind a payment, and no advertising SDK is in the build, enforced by a test that fails the suite if one ever appears.

How we built it

React, TypeScript and Vite, wrapped in Capacitor for the Android shell. Supabase for auth, Postgres, realtime and storage. libsodium does the cryptography, WebRTC handles calls, RevenueCat handles purchases, OneSignal delivers push.

One file is the most important place in the codebase: the seal boundary, the only module allowed to open or close a message body. Every query returns rows still sealed, and opening happens at the one layer holding both an identity and a peer key. Everything else only ever touches ciphertext.

Two tests exist to keep the product's claims mechanically true instead of aspirational. One fails if a plaintext body ever reaches a database insert; the other fails if an advertising SDK shows up in package.json or the Gradle build. The suite is 900+ tests across ~80 files, all pure logic pushed out of components and into lib/, and CI runs it with lint and typecheck on every push. A separate script replays every migration into a throwaway Postgres container and diffs the result against the single-file schema, so changing one without the other fails on my machine rather than on a live database with no undo.

Every native plugin degrades to a no-op in a browser: secure storage, the SQLite mirror, media pinning, push, purchases. Building the Electron desktop shell taught me to stop asking Capacitor whether it is running natively, because it answers yes on Electron, where none of those plugins exist. The app asks its own isMobileNative() instead. Left alone, the seed would have been written into localStorage while the UI reported hardware-backed storage, which is worse than a crash.

Challenges we ran into

Publishing, first. Apple and Galaxy store wants a D-U-N-S number. Google Play wants that as well or twelve testers on a closed track for fourteen days.

The bug I learned the most from: identity was keyed to the device rather than to the account. Add a second account on the same phone and it inherited the first one's private key. It got worse during a switch, because the session and the key derivation were separate state, so there was a render where the session had already become the new account while the keypair was still the old one, and the publisher wrote the wrong public key into the new profile row. Two phones then hashed different key material and every safety number, word list and art grid disagreed. It is all per-account now, and the pair is resolved during render so no caller can observe a mismatched one.

Answering a call on a locked phone whose app the system has killed was the other hard one. The caller's topic is joined from the notification rather than from the friend list, because the friend list arrives during the answer. The answering side announces itself the moment its topic goes live so the caller can re-offer at once, since broadcast has no replay. TURN credentials warm up while that happens. Capture only ever starts after the user has actually accepted: a microphone opened on a ring nobody answered is an app listening to a room that never agreed to it.

Dropping the content column also meant rebuilding things nobody thinks of as features. Search, conversation previews, and date and link extraction all had to be rewritten against the local mirror, which is the only plaintext copy that exists.

Accomplishments that we're proud of

The privacy claims are enforced, not just written down. The in-app transparency screen that lists what the server knows is built from live queries against the real schema, so a future migration that adds a readable column changes what the screen says instead of leaving it quietly wrong. A second screen says where the protection stops: metadata is not hidden, a rooted phone is a compromised phone, and anyone genuinely at risk should use Signal. Most of the industry oversells this part.

The monetization held up under its own rule. Light mode and the OLED-black theme are free, because charging for a screen someone needs to read outdoors would be a functional paywall wearing a cosmetic label. The largest donation tier hands over cosmetics that were already for sale, which is the only direction a payment is allowed to move in this app.

What we learned

A security claim means nothing unless something breaks when it stops being true. Once the build started failing on a plaintext body reaching an insert, every later feature got designed around "what would make that test fail" instead of "does this look private."

The second lesson is where a rule belongs. In a public repository, a check in the client is a suggestion: anyone can delete a line of TypeScript and rebuild the APK. So the sealed-exchange withholding is a row-level policy with no UPDATE grant behind it, and the write paths that a row policy cannot see got closed in migration 0034 instead of in the app.

What's next for Nearside

Rounding up twelve testers and getting through Play's closed track, then testing the iOS version, which has been configured for months and has never met a Mac. After that, metadata: the server still knows who talks to whom and when, even though it cannot read a word of what they say.

Built With

Share this project:

Updates

Submission history