ra-yavuz › doublethink

doublethink logo

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

PropertyHow
AuthenticationA 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 authorisationHolding 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 encryptionThe 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 operatorCreating 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-duplexEither 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 retentionA 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.

Disclaimer: doublethink is provided AS IS, WITHOUT WARRANTY OF ANY KIND. By design it carries other parties' private traffic and enforces access to it. You alone are responsible for how you deploy and secure it, for the data that flows through it, and for the consequences of any misconfiguration. The author and contributors are not liable for any harm, data loss, data exposure, or security incident, however caused. No security mechanism is perfect; evaluate whether it meets your own requirements before trusting it with sensitive traffic. By installing or running this software you accept these terms. See README for the full text.
The public instance at 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.