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.






















