ra-yavuz › doublethink

doublethink
A publish/subscribe message broker that is as easy to stand up as ntfy, but that you can actually trust with private data. ntfy is effortless because its topic name is the only gate, which is exactly why its topics are effectively public. doublethink keeps the minutes-to-set-up ergonomics and adds the thing ntfy omits: identity, authentication, and genuinely private channels.
A private channel admits only authenticated, authorised parties, and its payloads are end-to-end encrypted between the paired peers: the broker operator never sees the plaintext. Knowing a channel's name grants no access. Plaintext public topics are available too, opt-in, for ntfy-style open use.
What makes it private
| Property | How |
|---|---|
| Authentication | A channel is gated by one high-entropy shared secret the two parties hold. A client attaches by answering a fresh server challenge with a response only a holder of the secret can compute. The secret itself is never sent to the broker. Access without it is rejected outright. |
| Per-channel authorisation | Holding one channel's secret reaches nothing else. Knowing a channel name grants no access; the broker leaks nothing about whether a private channel exists. |
| End-to-end encryption | The encryption key is derived from the shared secret (which the broker never sees), then split per direction and sealed with NaCl secretbox. The broker forwards ciphertext it cannot read. |
| Self-service, no operator | Creating a private channel is one request that returns a secret. No admin, no pairing ceremony, no invite codes. The secret is the gate, share it like an ntfy topic, except it is unguessable and is what grants access. |
| Streaming, full-duplex | Either party sends at any time; many messages stream over one channel; a barge-in control message is never stuck behind the other party's stream. |
| Optional retention | A channel is ephemeral (online-only) by default. Opt in to retention and the broker stores messages so a peer that was offline can catch up on reconnect. Stored messages stay end-to-end-encrypted ciphertext: kept, not read. Retained channels need an account and are bounded by quotas and a TTL. |
See it, do not take my word for it
This runs against the live broker at api.caleidoscode.io, entirely in
your browser. It generates a fresh secret here (nothing is embedded), creates a
throwaway ephemeral channel, then has two parties exchange one message. You see
the exact bytes the broker relays next to the plaintext only a
holder of the secret can recover. Then it tries to read the same bytes with the
wrong secret, and fails. Nothing here persists.
The demo uses an ephemeral (non-retained) channel and tears down immediately, so it stores nothing and ages out at once. Rate-limited like any other client.
Run it
Cross-platform: Docker or a single native binary. Pick one.
# Docker, serve from a checkout (no image build):
docker compose up
# Docker, prebuilt self-contained image (distroless):
docker compose -f docker-compose.build.yml up --build
# Or a single native binary:
go build -o doublethink ./cmd/doublethink
./doublethink serve
The broker listens on :8080.
Android app
A sideloadable Android client lets you subscribe to topics from a phone and get a
notification the moment a message arrives. It supports encrypted private channels
(you hold the secret; the broker only sees ciphertext) and plaintext topics, points
at api.caleidoscode.io by default, and can talk to your own broker. Grab
the APK from the
releases page and
sideload it.
Honest limit: doublethink has no push gateway (that would mean trusting a third party with arrival signals), so the app receives by holding a live connection in a foreground service. While it runs, notifications arrive instantly; in deep sleep, delivery can be delayed until you open the app. Disabling battery optimization for the app makes it considerably more reliable.
Create a private channel
# One request, no operator. Prints the channel id and its shared secret:
doublethink channel create --prefix codespeak
# channel: codespeak/<random-id>
# secret: <high-entropy shared secret>
Share the secret with the other party over a trusted channel. Both parties then connect to the channel with that secret; whoever holds it can join and read the channel, and no one else can. The broker stores only a key derived from the secret (enough to check who may join, not enough to read messages), so the operator never sees your traffic. Plaintext public topics work ntfy-style, no secret, fully open:
curl -d "hello" http://localhost:8080/publish/mytopic
curl -sN http://localhost:8080/subscribe/mytopic # Server-Sent Events stream
Retained channels and accounts
The channel above is ephemeral: messages reach whoever is connected and are then gone. For a peer that may be offline at publish time (a backgrounded app, a reconnecting agent), create a retained channel so the broker stores its messages for catch-up. Retained channels need an account:
# 1. Get an account API key (shown once; the broker stores only a hash of it):
doublethink account create
# 2. Create a retained channel with that key:
doublethink channel create --prefix codespeak --retain \
--account <id> --api-key <key>
On reconnect a subscriber sends the last sequence number it saw and gets only
what it missed, in order, then resumes live. Stored messages are still
end-to-end-encrypted ciphertext the broker cannot read. A public instance bounds
use: retention is capped per channel (message count and byte size, oldest
evicted) and aged out by a TTL (default 24h, max 7d); each account has a storage
quota (256 MiB) and a channel cap (100); messages are size-capped (256 KiB); and
creation, publishing, and connections are rate-limited per source. An operator
can raise a preferred channel's limits with doublethink admin set-limit
(authenticated by the DOUBLETHINK_ADMIN_KEY the broker runs with);
the admin key controls limits and grants and reads usage metadata only, it never
grants access to any channel's payloads.
Permanent channels, without the admin seeing your secret
For a channel that should persist indefinitely (or for any TTL up to infinity), the operator pre-authorizes it with a single-use grant ticket and hands it to you. You redeem the ticket while creating the channel with your own secret, so the operator authorizes a durable channel without ever learning the secret and cannot read it:
# Operator issues a grant for a permanent channel (admin key):
doublethink admin grant --channel perm/team-debug --ttl-sec 0
# grant ticket (single use, valid 60 minutes to redeem): <ticket>
# You redeem it with your OWN secret (no admin key needed):
doublethink channel create --channel perm/team-debug --ticket <ticket>
# secret: <high-entropy secret> the operator never sees this
--ttl-sec 0 means never expire. The channel's policy comes from the
ticket, not from the client, so a leaked ticket cannot widen its own
authorization; it is single-use and expires if unredeemed. Storage is in Redis
with about once-a-second persistence: a clean shutdown loses nothing, a hard
crash can lose up to roughly the last second of writes ("permanent" means the
data does not age out, not crash-proof to the millisecond). The full admin API
and the grant flow are in the
admin reference.
The grant says nothing about encryption; you choose at redeem
time. Redeem with a secret and you get the end-to-end-encrypted channel above.
Redeem without one and you get a plaintext permanent topic
reachable on the open ntfy-style path with no key: anyone can
POST /publish/<topic> and GET /subscribe/<topic>,
and a fresh visitor catches up on the whole backlog. That is exactly what a
public guestbook wants, where end-to-end encryption would add
key management for no benefit (everyone can read it anyway). A plaintext topic
deliberately drops the "operator cannot read it" property for that topic, which
is the right trade for public data and is documented as a visible choice, not a
default.
The honest limits
The broker is payload-blind, not metadata-blind: it routes on the channel id and the message type, id, and timestamp, and it sees ciphertext sizes and timing. The channel key is static for the life of the shared secret, so this release does not yet provide forward secrecy; the model is also symmetric (both holders of the secret can encrypt and read, so there is no per-sender proof and no way to evict one party without rotating the secret), and it offers at-most-once online delivery with no retention for a party that was offline. These are deliberate, documented scope limits, not accidents; see the security doc.
api.caleidoscode.io: a
free, best-effort service with no SLA and no guarantees. It
may be rate-limited, wiped, or shut down at any time without
notice. Do not depend on it; if you need guarantees, self-host (the
single-binary design exists for exactly that). Use is subject to an
acceptable-use policy (no illegal use) and an abuse/takedown process keyed on
channel id; payloads are end-to-end encrypted and not monitored. Report abuse to
yavuzramazan1994@gmail.com. The public
/stats endpoint exposes aggregate counts only, never IPs or channel
ids. Full terms:
legal & acceptable use.