wycliffogembo87

wycliffogembo87

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): https://stelgano.com/spec

- 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.

Most Liked

wycliffogembo87

wycliffogembo87

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.

wycliffogembo87

wycliffogembo87

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.

derek-zhou

derek-zhou

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.

Where Next?

Popular in Discussions Top

thojanssens1
It would be nice to be able to define a redirect from one route to another from the router.ex file. E.g.: redirect "/", UserController, ...
New
AstonJ
I’ve just started the Phoenix part of the utterly brilliant online course by @pragdave. On generating the Phoenix app he uses the --no-ec...
New
Ankhers
Just a little information upfront. Generally speaking, if I feel like I need to either break a pipe chain or use an anonymous function in...
New
rower687
Hi all, I’ve been reading a lot about the “let it crash” term and how supervising processes and the whole messaging passing make an elixi...
New
jer
I’ve been using umbrellas for a while, and generally started off (on greenfield projects at least) by isolating subapps based on clearly ...
New
PragTob
Hey everyone, this has been on my mind for some time and I’d love your input on it! TLDR: I feel like maps are superioer for storing and...
New
eteeselink
Hi all, In the last days, two things happened: A blog post titled “They might never tell you it’s broken” made the rounds. It’s about ...
New
MarioFlach
Hello, I want to share a project I’ve been working on for a while: https://github.com/almightycouch/gitgud Background Some time ago I ...
New
jesse
Hi everyone, I hesitated to post this here because I don’t want you to think I’m spamming, but I’ve been working on a Platform-as-a-Serv...
New
axelson
Decided against including more info in the title, but the gist is that Plataformatec sponsored projects will continue with the assets bei...
New

Other popular topics Top

sen
Hi All, I set a environment variables in dev.exs , like below code. when i start server, how can i set the ${enable} value? thanks. d...
New
danschultzer
None of the current solutions worked well for me, so I went ahead and built a user management system from scratch. This project took far...
548 29603 241
New
baxterw3b
Hi guys, i’m new in the Elixir world, and i have to say, that i love it! i’m having some problem to understand anonymous functions with ...
New
greenz1
I have a phoenix application from which a user can download multiple(5-6) files of size 1MB. I couldn’t find anything related to sending ...
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod – where is this set? Thanks.
New
belgoros
I’m not a pro in using Regex and can’t figure out why the following behaviour happens, especially if we take into account the difference ...
New
Qqwy
Original source of discussion: This topic on the Pragmatic Programmers’ Functional Web Development with Elixir, OTP, and Phoenix forum. ...
New
alice
Hey, Just curious what are the main benefits of Elixir compared to Clojure? When is Elixir more useful than Clojure and vice versa? Th...
New
boundedvariable
I am going through the kafka architecture. All the features what the kafka is providing are already in Erlang. I would like hear your opi...
New
joaquinalcerro
Hi there, I am working with Ecto-Postgresql and I need to call all of the records from a specific table but the table has 40,000 records...
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement