sTELgano - a zero-knowledge messaging app on Phoenix 1.8 + LiveView (feedback welcome on the unauthenticated channel design)

Hi all,

Wanted to share a side project I just launched, and open a thread on a few Phoenix patterns I went with that I’m not 100% sure are idiomatic.

sTELgano (pronounced stel-GAH-no — a portmanteau of stegano-graphy and TEL) is a privacy-focused messaging app where the “shared secret” between two people is a fake phone number they each save in the other’s real contact card. You enter that number and a PIN at https://stelgano.com, and the browser derives all keys locally. The server only ever sees SHA-256 hashes and AES-256-GCM ciphertext.

The threat model is deliberately narrow and stated clearly throughout: it protects against an intimate-access attacker (a partner who picks up your unlocked phone), not state actors. I wanted to be upfront about this rather than imply more than the system actually provides.

Worth acknowledging up front: this sits alongside other zero-knowledge Phoenix/LiveView projects the forum has featured — [Mosslet](https://mosslet.com/) and [Metamorphic]( Metamorphic - Zero-knowledge, E2E encrypted habit tracker built with Phoenix LiveView ) in particular. Different product categories (privacy-first social, habit tracking) but a shared server-blind philosophy; sTELgano is the messaging-shaped sibling, with an unusually narrow threat model as its differentiator.

There are three parts I’d most like feedback on from the Phoenix crowd:

1. Fully unauthenticated socket, auth inside `join/3`

The chat uses a raw Phoenix Channel on a session-less socket — no cookie, no token, no `connect/3` auth:

def connect(_params, socket, _connect_info), do: {:ok, socket}

All access control lives inside AnonRoomChannel.join/3, which validates an (room_hash, access_hash, sender_hash) triple — each a 64-char hex SHA-256 — against the DB. It felt unusual to have a socket with no notion of identity at all, but it keeps the auth surface tiny and the socket stateless. Is there a more idiomatic way to model this in Phoenix that I’m missing?

2. N=1 invariant enforced at two layers

At most one message ever exists per room. Replying atomically deletes the previous one:

def send_message(room_id, sender_hash, ciphertext, iv) do

Repo.transaction(fn ->

existing = current_message(room_id)

if existing && existing.sender_hash == sender_hash do

Repo.rollback(:sender_blocked)

end

if existing, do: Repo.delete!(existing)

    %Message{} |> Message.changeset(...) |> Repo.insert!()

end)

end

Application-layer transaction plus a UNIQUE index on messages.room_id as a backstop against concurrent inserts under READ COMMITTED. I went back and forth on whether the DB guard is belt-and-braces or actually necessary — curious how others would model this.

3. LiveView as a pure state machine, crypto in JS hooks

ChatLive holds no crypto state server-side. It flips between :entry → :deriving → :connecting → :chat → :locked → :expired atoms and delegates every cryptographic operation to a colocated JS hook, which handles PBKDF2 key derivation (600k iters, OWASP 2023), AES-GCM encrypt/decrypt, and the Channel lifecycle. The LiveView orchestrates screens and pushes events; the hook holds the keys.

This is the first time I’ve deliberately kept secrets out of LiveView assigns — would be interested if anyone has shipped something similar and hit sharp edges I haven’t.

Stack: Elixir 1.18, Phoenix 1.8, LiveView, PostgreSQL, Oban (for TTL-based room expiry), Req, Tailwind v4. Zero npm cryptographic libraries — Web Crypto API only.

- Repo: GitHub - sTELgano/sTELgano: Private messaging that hides in your contacts. · GitHub (AGPL-3.0)

- Crypto spec (sTELgano-std-1): Spec — sTELgano

- Single-file crypto implementation: assets/js/crypto/anon.js

Happy to answer anything about the design. Particularly interested in:

- whether the unauthenticated-socket pattern has a more idiomatic counterpart

- thoughts on the N=1 transaction (races, pitfalls, better ways to express “turn-based”)

- whether :telemetry would be worth wiring in for the aggregate country/daily counters (currently plain Ecto writes)

Cheers.

4 Likes

Not my area of expertise, so apologies if this sounds naive.. but can the security be upgraded or is it the platform itself (client side and/or server-side) you deem unsuitable/rendering any such upgrade futile? Please expand on that.

Thanks for the question — I’m not a security researcher either, so let me answer in plain terms based on what I learned while designing the threat model.

The short answer: the platform (browser + server) is the ceiling, not the crypto. I could swap PBKDF2 for Argon2, or AES-GCM for XChaCha20, and it wouldn’t move sTELgano into “protects against governments” territory — because the web itself isn’t suited to that threat tier. A few well-known reasons:

  1. The server ships the code. Every visit re-downloads anon.js from stelgano.com. If the server is compromised or legally compelled, it can serve a modified version that leaks the key. Native apps can be code-signed and pinned; websites can’t. This is the main reason Signal is an app and not a site — Tony Arcieri’s 2013 essay “What’s wrong with in-browser cryptography?” is still the canonical reference.
  2. Metadata leaks outside the crypto. TLS SNI and DNS tell any upstream observer that a device connected to stelgano.com. No amount of payload encryption hides the connection itself.
  3. No hardware-backed key storage in the browser. Native apps can use Secure Enclave / StrongBox. Browsers give you sessionStorage, which is cleartext to anyone with the device unlocked.

So I’d say the upgrades would be mostly futile for the state-actor threat tier — it’s a platform limit, not an implementation one. Which is why I scoped sTELgano to the intimate-access attacker specifically: the web platform is actually well-suited to that problem. Anyone who needs protection from law enforcement should use Signal on a hardened device; I’m not trying to duplicate that.

Worth noting that Arcieri’s post argues against in-browser crypto for all threat models, including mine. I disagree on that specific point — for the intimate-access attacker, the server-compromise risk he focuses on is much less salient than for the nation-state case. But his mechanical analysis of why the platform is a weak substrate is spot-on, and that’s what I’m pointing to.

Happy to be corrected by anyone with more security background than me.

1 Like

But the no. 2 applies to Signal and the likes as well. For as long as there’s a server there will be an IP address with or without a domain name.

No. 1 can be solved by the likes of Electron or even: GitHub - elixir-desktop/desktop: Building Local-First apps for Windows, MacOS, Linux, iOS and Android using Phoenix LiveView & Elixir! · GitHub

When it comes to no. 3 it’s likely already too late, so it’s not that it would matter much in real life.

All fair points, you’re right on at least two of three. Let me engage with them.

On no. 2 (metadata): I overclaimed. Any client-server app leaks IP/SNI, Signal included. The real differentiator at that layer is things like Sealed Sender and whether the app can be routed over Tor — not web vs. native. Should have cut that point.

On no. 3 (hardware keys): Also fair, and even more so for sTELgano specifically. The design derives keys on demand from phone + PIN — no persistent keys sitting on the device for hardware storage to protect. You’re right that it barely matters here.

On no. 1 (code delivery) — this is the interesting one, because you’re technically correct that Electron or elixir-desktop solves the “server ships the code” problem. But doing so would destroy the thing sTELgano is actually trying to be.

The core design constraint isn’t “maximum cryptographic security” — established tools already occupy that slot and do it better. The constraint is simpler: a partner unlocks your phone, scrolls through apps, opens messages, checks recent activity. What do they find? With sTELgano today, no app icon, no app-drawer entry, no home-screen tile, no entry in Settings → Apps. The moment I ship a bundled binary, there’s a “sTELgano” icon somewhere on the device. The passcode test fails before crypto enters the picture at all. I rejected shipping even a PWA for the same reason — install banners, chrome://apps, iOS long-press “Add to Home Screen” all leak.

To be honest about where this argument stops working, though — “invisible” isn’t absolute. The passcode test protects against casual inspection, not forensic inspection. A few residual weaknesses a careful partner could exploit:

  1. Browser history / URL autocomplete. The site sets Cache-Control: no-store and recommends incognito; a planned improvement is to keep the URL pinned at / regardless of in-app state via replaceState, so history shows only stelgano.com and not any in-app route. But the domain itself will always appear in browser history outside incognito — that’s a browser-level behaviour a site can’t override. Residual concern, partially mitigated.
  2. The homepage reveals the product. Anyone who types the URL learns what it is. So invisibility is conditional on them never getting the URL in the first place — which is exactly what the browser-history issue compromises.
  3. The fake number in contacts. Mitigated by the usage pattern (save it as a second number under an existing contact, not as a new entry), but still requires user judgment.

So the honest framing is: sTELgano raises the cost of discovery against a casual intimate-access attacker. It does not make you invisible to a forensic one.

On who this is actually for — sTELgano isn’t trying to be a better messenger than established tools, they’re excellent and I recommend them to most people. It’s trying to serve a specific user that the messenger category as a whole structurally excludes: someone whose partner actively monitors their phone, including installed apps, Settings → Apps, recent SMS, and account activity.

For that user, the bottleneck isn’t “which messenger has the strongest crypto.” It’s “can I adopt a private channel at all, without the act of adoption being itself what gets me caught.” Every native app — however well-designed — appears in the app drawer, the app list, OS-level backups, the recent-apps switcher. Every phone-verified account generates a verification SMS. For most people that’s fine. For the specific demographic I built this for, each of those is the exact signal they’re trying to avoid.

The web sidesteps both. A URL opened in incognito leaves no installed app, no account, no SMS, no settings-panel entry. That’s not a cryptographic argument — it’s a “can the tool be used at all by this user” argument, and it’s why I chose the web despite knowing the crypto tradeoffs it costs me.

If your threat model doesn’t include being monitored to that degree, you have better options than sTELgano, and I’d point you to them. The product is narrowly aimed at people whose adoption visibility is itself the threat.

Good thread, appreciate your feedback.

got it

Then what is the point of crypto on the client side if there is no protection against the server operator? If the server can be trusted, we may as well rely on SSL alone.

BTW I tried to use the site but the js spin like crazy on FIrefox.

I appreciate the question+feedback.

The bit I’d push back on is the framing “the server can be trusted.” That’s not actually the claim. The threat model says the system does not guarantee protection against an adversary who can compel the operator — which is a statement about limits, not an assumption of trust. The difference matters, because it changes what the client-side crypto is for:

  1. Passive compromise of the server. If the DB is dumped, a backup is leaked, or the server is hacked by someone other than the operator, what falls out is hashes and ciphertext. With SSL alone, those events leak plaintext. That’s not hypothetical — it’s most of the security incidents that actually happen in the wild.
  2. Post-hoc operator change. The operator today may be me, behaving honestly. The operator a year from now may be whoever buys the domain, or me under subpoena, or me after selling the project. Client-side crypto means past ciphertext doesn’t become readable just because the operator’s intentions (or identity) shift.
  3. No correlation surface at rest. room_hash has to be computed client-side to preserve the property that the server never sees the phone number in plaintext. If it were computed server-side after SSL termination, the server would know the number every time someone logged in, and that knowledge would live in memory, in logs, and in anything the operator chose to persist. The hash isn’t only a lookup key — it’s a deliberate barrier against the server learning the secret even in memory.

Where your point lands cleanly: client-side crypto cannot defend against an adversary who can push malicious JavaScript to the user’s browser. That is the browser-bootloading problem, and it’s exactly why the threat model is honest that a government compelling the operator is out of scope — they don’t need to decrypt existing messages, they can just serve you a modified anon.js that exfiltrates your PIN. I agree that’s the unfixable limit, and the docs say so plainly.

So: client-side crypto is useful against the wide class of passive / post-hoc / non-governmental compromises, and honest about what it can’t do against an active one. SSL alone would cover none of that class.

On Firefox spinning — that’s a bug and I’d like to chase it. The PBKDF2 step at 600k iterations is expected to take roughly 1.5–2.5s on the main thread (shown behind a loader), but “spinning like crazy” sounds like something else — potentially a loop or a Web Crypto quirk specific to Firefox. Could you share:

  • Firefox version and OS?
  • Whether it’s at the initial derivation step (after hitting Enter on the entry screen) or later?
  • Any console errors visible in devtools?

Happy to open a GitHub issue and link back here once I can reproduce. Thanks for trying it — bug reports are alot.

Quick follow-up: your pushback pushed me to look at this more carefully, and you were right that the original threat-model framing left the “operator compulsion” question ambiguous. I’ve added an Operator Disclosure section to /security#operator-disclosure that states the limit directly — the operator does not pre-commit to refusing lawful process (unlike Lavabit, I lack the financial muscle to contest a lawful order), so you should assume compliance. An honest non-commitment is a more useful claim than vague “we do our best” reassurance.

Thanks for pressing on it. The Firefox issue is still on my list — I’ll report back once I can reproduce.

So, it protects against good operators but not against rogue operators (the irony). While the 3 kinds of compromises are all valid, they do not apply much in this case because this is just a chat room. and you even have N=1 invariant enforced, do you really need to persist anything server side at all?

“Spinning like crazy” was probably temporary or an overstatement; after I restarted firefox (v150 on Windows) it seems to work much better. However, there is still a general feeling of slowness; does it do some heavy lifting in js in the background?

One more thing: Your users will most likely ask for more than just text chat; passing files is a very common usage of secure chat rooms. Then you probably have to persist something and the client side crypto will be more justified.

To achieve this, the weakest link will be exchange of the shared secret with the identities you want to chat with. I guess in this case the parties would need a back channel, but then you have a chicken and egg problem.

Thanks for your feedback — a couple of your points land and a couple I’d push back on.

On the “good operators vs rogue operators” framing. It collapses three adversaries into two in a way that loses the useful distinction. The rogue operator (actively pushing malicious JS) is one adversary and, as I conceded, the architecture cannot stop them. But there are two others the client-side crypto genuinely does protect against, and neither requires the operator to be rogue:

  • Passive compromise — the operator is behaving honestly; the DB just gets dumped in a breach, a backup leaks, or an opportunistic intruder runs pg_dump. The operator is not the attacker; their data just went somewhere it shouldn’t. This is the most common class of real-world incident.
  • Future operator — the domain is sold, the business is acquired, I die, the project is handed off, I fall under legal pressure. The current operator’s good behaviour doesn’t bind the next one, but the ciphertext does.

So the irony isn’t quite what it looks like — the defence is against good operators, in the sense that their present-tense good behaviour doesn’t retroactively protect data once a key they never possessed becomes moot to the future state of the system.

“Do you need to persist anything at all?” — you’re right that server-side utility is low. Still: yes, but the minimum.

  • rooms exists so two users with the same shared phone number can find the same pigeonhole without exchanging a session ID over a side channel. The passcode-test UX (“type a number from your contacts”) requires a durable lookup target.
  • room_access exists because the 10-attempt lockout on access_hash has to be server-enforced — a client can’t rate-limit itself.
  • messages (single row) exists as a dead-drop: one user writes, the other reads asynchronously. Pure peer-to-peer would require both users online simultaneously, which breaks the medium.

N=1 + encryption means the DB is effectively “a pigeonhole with a rate-limiter.” Every row is as small as I can make it and is hard-deleted on reply or TTL expiry. Zero is the wrong answer; minimum is the design.

Firefox on Windows. Good that the restart helped. The “general feeling of slowness” is probably one of two things:

  1. PBKDF2 600k iterations at login — ~1.5–2.5s on the main thread, shown behind a loader, happens once per session. Intentional cost per OWASP 2023.
  2. Glassmorphism backdrop-filter: blur() — Firefox on Windows has a known perf gap vs Chromium here, especially on older GPUs. No background JS (no service worker, no polling, no web workers), but compositing is more expensive than a flat UI would be.

If you can, a DevTools → Performance recording during a session would pin which one. Happy to dig in if you share the profile (or file an issue).

File sharing / multimedia. Yes, users will ask for it (it’s the most common extension beyond text), and yes, files at rest would make the client-side crypto case more obviously compelling than text alone does.

The blocker isn’t architectural (files encrypt and store fine in the same ciphertext-only model), it’s operator-risk. Once media flows through the service — even encrypted — the operator inherits compliance obligations that a text-only service sidesteps, most critically around CSAM reporting mandates and, indirectly, payment-processor categorisation (image-hosting platforms tend to be treated more strictly regardless of whether the operator can see the content). None of that is insurmountable, but it requires legal backing and content-moderation infrastructure that a solo operation does not yet have.

So: text-only for now because operator-risk doesn’t support multimedia; revisit when that changes.

Thanks, short term capture of message history etc can be done with a GenServer. This way, the persistent risk can be side steped. Without persistent, the passive compromise or future operator risks will disappear.

I am not a lawyer but I don’t think this is enforceable in short lived, small sized file sharing in private chat.

I also suggest you to dial down on iterations and fancy graphic transitions. I am on a decent laptop and I still feel slow. Your users might want to use a cheap phone.

Yes, the key-exchange problem is real — I don’t think there’s a way around it for any E2E system, but it’s worth being specific about how it lands here.

The “back channel” in the design’s expected use case is usually the relationship itself. Intimate-access is the threat model, which means the other party is almost always someone you already know well — a partner, a family member, a close friend. The exchange is typically “let’s use this random number: 555-2341” said out loud at a dinner table, 30 seconds, no ceremony. For that threat model, that channel is plenty.

That said — every E2E system has the same problem, they just pick different trade-offs:

  • Signal solves it with phone-number-based discovery, which (as I conceded earlier on the metadata point) means the server knows who’s talking to whom.
  • Matrix uses server-brokered room introductions.
  • Session / Tor / Briar all require exchanging some identifier through another channel first.

sTELgano pushes the exchange fully out-of-band, which is a deliberate trade: no phonebook discovery, no server-side contact graph, in exchange for a trivial-in-practice face-to-face coordination step.

One nuance that softens the chicken-and-egg framing slightly: the phone number isn’t the whole credential. The PIN is chosen independently by each party and never transmitted anywhere. An attacker who intercepts the exchange channel gets the number but not the PIN — they’d still have to get past the 10-attempt server-side lockout, which caps practical brute-force. For a passive eavesdropper (overheard, screenshotted), that matters; for an active attacker with full device access, it doesn’t — which is consistent with the stated threat model.

The concession: for threat models wider than intimate-access, the out-of-band exchange is genuinely a weaker link than a system with forward secrecy and a double-ratchet. That gap is part of why the docs are explicit sTELgano isn’t for those threat models.

Three good suggestions.

GenServer for message storage — genuinely interesting, I hadn’t properly considered this. Moving the single message into in-memory process state would sidestep passive-compromise and future-operator risk for content. Trade-off is durability across deploys/restarts. Hybrid shape — keep rooms and room_access in Postgres (lookups + lockout counter need durability), move message content in-memory per-room — is probably the right direction. Worth prototyping.

CSAM enforceability for small/ephemeral services — fair point, “enforcement likely” isn’t the right framing. My concern is less court-risk than operational surface: payment processors and hosting providers categorise services on their own criteria, and a de-banking event is catastrophic for a solo operator regardless of legal specifics. But conceded — it’s not the slam-dunk blocker I framed it as.

Performance on a decent laptop — serious signal, taking it. Two specific things I’ll look at: moving PBKDF2 into a Web Worker so it doesn’t block the main thread (600k stays for security, stops freezing the UI), and adding a flatter fallback for the backdrop-filter glassmorphism on lower-end devices and Firefox+Windows. Cheap-phone users are in the target market — not an edge case.

They can be in memory too. lockout counter can be reset at reboot without lost of functionality. As for room look up, if you do not persist anything else, then room itself does not need persistence (nothing there to look up). You can regard every new channel access as room creation.

On key exchange and pin code: People now days are notoriously bad at memorizing phone number and passwords. I totally rely on my address book and password manager. So, if I have an item in my phone contact as “my secret sTELgano number with my paramour” as a note, things will not bode well. As for pin code, if a user has >1 channels on sTELgano and browser autofill the pin code based on domain name, things will fall apart too.

Fully in-memory — technically yes, but it becomes a different product. Concrete consequences I’d want to not lose:

  • Lockout counter reset-on-reboot = 10 attempts per uptime window, not per attacker career. Anyone who can trigger or wait for restarts refreshes their budget; the 30-minute time-based lockout guarantee becomes unenforceable — legitimate users also can’t know when theirs resets.
  • “Every access is creation” works for synchronous / pure-P2P products. sTELgano is asynchronous: A sends, goes offline, B reads an hour later. A deploy between those two events drops the message. Reliability regression, not a privacy gain.
  • TTL enforcement dies without persistence. Paid tier (1 year) and free tier (7 days) both need to survive restarts; in-memory resets the clock on every boot.
  • Monetization needs persistence. extension_tokens have to outlive restarts or payments evaporate on the first deploy.

For the current product (asynchronous, time-based rate-limits, paid tiers), minimum persistence is the minimum. A stricter ephemeral product could work fully in-memory — but that’s a different product with a different UX contract. Happy to be convinced otherwise if there’s a specific version I’m missing.

Contact-note concern — let me reframe. I don’t think a new contact needs to be created at all. You add the generated steg number as an additional number on an existing real contact: John Doe already exists in your contacts with +254 722 222222; you just add +254 733 444444 as a second number on the same card. A suspicious partner browsing contacts sees “John Doe: two numbers” — completely unremarkable (work/personal, dual-SIM, new number, whatever). No note, no label, no new contact to justify — the contact’s name is the implicit label because it’s literally the person’s name. That mirrors what the product copy already implies (“saved in the other’s real contact card”), but the onboarding UX probably doesn’t make this pattern explicit enough today. Fair feedback — filing as a UX clarity fix.

PIN autofill by domain — real problem, you’re right to surface it. Current state: the app uses autocomplete="one-time-code" plus non-standard field attributes to discourage browser password managers from offering to save or fill the PIN. But browser heuristics change frequently, and a user with multiple channels on stelgano.com is exactly the case where “suggest the same PIN for this site” would break everything. Worth an actual audit on current Chrome/Firefox/Safari/Brave, not just trust the attributes. Filing.

Memorization load for users with >1 channels — unresolvable trade-off. Unique PIN per channel = more secure, harder to remember. Same PIN everywhere = single point of failure. Password manager = breaks the passcode test. The current design takes the security side, which means users with 3+ channels legitimately have memorization load. Worth naming in the docs as a known limit rather than pretending it isn’t there.

1 Like

Thanks for the shout out :slight_smile: I tried to visit stelgano.com but couldn’t get the websocket to connect/page to load. Are you running into deployment/config issues with fly? Anything I can help with?

Edit to say looks to be up now, really cool idea :heart:

For me, I start to think about useability questions:

  • n = 1; perhaps this is configurable at some step in the flow because maybe people need a little history at times and not at others
  • how does the admin dashboard fit in to this, metrics for something that is supposed to be invisible? maybe this where you can give people more control/configurability over the way the service works for them

I haven’t had a moment to look at the code or more in depth, but cool that you open sourced it too.

I didn’t know this is the intended usage pattern. Text only, 1 on 1 only, enforced N=1, Async communication make it very unique; and you will face quite some initial inertia.

If you decide to go down this route, then file/pic upload may not be very high on your priority list. Voice recording should be higher.

1 Like

“Didn’t know this is the intended pattern” — that’s the UX signal that stings. If a technically engaged reader missed that async-text-only is deliberate after this much discussion, casual readers certainly will too. Filing a copy fix so the homepage and /about state it up front rather than leaving it implicit.

“Initial inertia” — accepted. The combination (text-only, 1:1, N=1, async) has no existing mental model to inherit from WhatsApp/Signal/Telegram — every constraint has to be re-justified rather than carried over. That’s the cost of the positioning; it’s worth paying for the population that actively wants this shape, and I’m prepared for the product to stay niche because of it.

“Voice over file/pic uploads” — I hadn’t properly considered this, and you’re right the ordering was wrong in my head. A few reasons voice has a materially different operator-risk profile than images:

  • Payment-processor categorisation differs — voice-messaging isn’t placed in the same merchant-risk bucket that image-hosting services fall into. That maps directly to the operational-surface concern I named earlier.
  • DMCA exposure is minor — short voice notes don’t attract the same active copyright-monitoring apparatus as image/audio/video platforms.
  • Voice fits the async rhythm better — a 30-second note is turn-based by nature in a way a snapshot isn’t.
  • Format is cleaner — smaller file sizes, no EXIF / geolocation metadata to strip before encryption.

Updated position: when operator-risk eventually supports any multimedia, voice is plausibly the first unlock — not images. Still not a near-term build — “text-only for now” remains the stance — but voice-before-images is a different ordering than where I’d landed, and it’s the right one. Thanks for the push.