This server forwards messages between clients. It does not read them. Every client encrypts its own messages before sending (X25519 key exchange + NaCl Box / XChaCha20-Poly1305); the relay only ever sees ciphertext and the two address strings involved. There are no accounts and no passwords - you pick any address string you like and use it, or derive one from your own public key if you want it to actually mean something (see self-certifying identity below).
See also, live demos running entirely over this relay:
These are the public parameters you need to point a client at this relay. There is nothing secret here - no password protects "using" the relay, since anyone can register any address. The only thing that is actually secret is each client's own private key, which never leaves that client.
| Transport | Host | Port | TLS | Notes |
|---|---|---|---|---|
| TCP | nowwx7.work | 8443 | yes (Let's Encrypt) | 4-byte length-prefixed JSON frames |
| WebSocket | nowwx7.work | 8444 | yes | primary WS endpoint |
| WebSocket | nowwx7.work | 443 | yes | fallback port, same socket also serves this docs page over HTTPS |
| UDP | nowwx7.work | 8445 | no (payload is still E2E encrypted above this layer) | requires a session token returned at registration |
Certificate: real, publicly trusted (Let's Encrypt) for nowwx7.work. Standard certificate verification works - do not disable TLS verification in your client, it is not necessary here and a self-signed-style config would mean you're doing something wrong.
TCP frames a JSON object with a 4-byte big-endian length prefix. WebSocket sends the same JSON object as a single text frame. UDP sends one JSON object per datagram.
{"t":"register","addr":"alice"}
{"t":"register","addr":"myalias","pubkey":"base64 pubkey"}
{"t":"send","to":"bob","pub":"...","nonce":"...","ct":"...","id":"optional-msg-id"}
{"t":"ack","to":"bob","id":"optional-msg-id"}
{"t":"capabilities"}
{"t":"find_match","game":"pong"}
{"t":"join_room","room":"lobby"}
{"t":"room_send","room":"lobby","ct":"..."}
{"t":"subscribe","topic":"home/+/temp"}
{"t":"unsubscribe","topic":"home/+/temp"}
{"t":"publish","topic":"home/kitchen/temp","ct":"...","retain":true}
{"t":"registered","addr":"alice","queued":0}
{"t":"deliver","from":"alice","pub":"...","nonce":"...","ct":"...","id":"optional-msg-id"}
{"t":"ack","id":"optional-msg-id","from":"bob"}
{"t":"queued","to":"bob"}
{"t":"error","msg":"..."}
{"t":"waiting","game":"pong"}
{"t":"matched","game":"pong","peer":"someone_else"}
{"t":"joined_room","room":"lobby","members":3}
{"t":"room_deliver","room":"lobby","from":"someone_else","ct":"..."}
{"t":"subscribed","topic":"home/+/temp"}
{"t":"unsubscribed","topic":"home/+/temp"}
{"t":"message","topic":"home/kitchen/temp","from":"someone_else","ct":"...","retained":false}
UDP is connectionless, so registering returns a token. Every later datagram must carry it, or it is silently dropped (stops basic source-spoofing floods):
register: {"t":"register","addr":"carol"}
reply: {"t":"registered","addr":"carol","token":"9f2a...","queued":0}
send: {"t":"send","from_addr":"carol","token":"9f2a...","to":"bob","ct":"..."}
ack: {"t":"ack","from_addr":"carol","token":"9f2a...","to":"bob","id":"..."}
subscribe: {"t":"subscribe","from_addr":"carol","token":"9f2a...","topic":"home/+/temp"}
publish: {"t":"publish","from_addr":"carol","token":"9f2a...","topic":"home/kitchen/temp","ct":"...","retain":true}
(every UDP message that isn't register or capabilities needs from_addr + token - same rule as send.)
A generic pairing primitive, not specific to any one game. Send {"t":"find_match","game":"<tag>"} and the relay either replies {"t":"waiting"} (you're now queued) or {"t":"matched","peer":"..."} immediately if someone else was already waiting under the same tag - and that peer gets a matched message too, pointing back at you. From there, message each other directly with send, same as any other pair of addresses. Queues are first-come-first-served and per-tag.
A basic broadcast primitive for things like a shared chat room, as opposed to 1-on-1 pairing. {"t":"join_room","room":"<name>"} joins (creating the room if it doesn't exist yet) and replies with the current member count. {"t":"room_send","room":"<name>","ct":"..."} broadcasts to every other member of that room as a room_deliver message. Leaving (disconnecting) removes you automatically. Rooms are not end-to-end encrypted by default - anyone in the room, and the relay itself, sees whatever you put in ct, so encrypt it yourself if it matters.
If you send to an address that isn't currently connected, the relay doesn't just error out - it buffers the deliver frame for that address (like XMPP offline storage) and tells you {"t":"queued","to":"..."} instead of rejecting it outright. Each address gets up to ~500 buffered messages, held for up to ~24 hours; past either limit the oldest ones are dropped first. When that address finally registers, the whole backlog is flushed to it - in order, as normal deliver frames - before the registered ack, which itself reports how many were just flushed: {"t":"registered","addr":"...","queued":N}. If N frames arrive before the ack does, that's the backlog; nothing special for the client to do about it except read the deliver frames normally.
Optional delivery confirmation: a sender can attach "id":"..." to a send - the relay passes it through unchanged onto the resulting deliver frame. The recipient can then confirm receipt with {"t":"ack","to":"<original sender>","id":"..."}, which the relay forwards back as {"t":"ack","id":"...","from":"<recipient>"}. Acks use the same store-and-forward path if the original sender has since gone offline.
Rooms are fixed, flat, named groups. Topics generalize that into proper pub/sub, same idea as MQTT: hierarchical names like home/kitchen/temp, and subscribers can use wildcards to match more than one topic at once.
| Wildcard | Meaning | Example |
|---|---|---|
| + | matches exactly one segment | home/+/temp matches home/kitchen/temp, not home/temp or home/a/b/temp |
| # | matches the rest of the topic (must be last) | home/# matches home/kitchen/temp, home/garage/door, and home itself |
Wildcards are only meaningful in subscribe - a publish always targets one concrete topic. Matching subscribers (including the publisher, if it also happens to be subscribed) get {"t":"message","topic":"...","from":"...","ct":"..."}. Unlike send/rooms, publish never queues for offline subscribers - if you weren't subscribed at publish time, you missed it (unless it was retained - see below).
Publish with "retain":true and the relay remembers that payload as the last-known value for that exact topic. Any new subscription whose filter matches a topic with a retained value gets it replayed immediately, right after the subscribed ack, marked "retained":true - handy for "what's the current state" rather than "tell me about changes from now on". Publish an empty ct with retain:true to clear it. Exactly one retained value is kept per exact topic (not per filter) - same semantics as MQTT retained messages.
Still no accounts, no passwords, no server-side registry to trust - but if you want an address that's actually yours and can't be impersonated, derive it from your own public key instead of picking an arbitrary string:
addr = base32(sha256(pubkey))[:16]
Send {"t":"register","pubkey":"<base64 pubkey>"} with no addr and the relay computes this for you and uses it as your address. Anyone can verify an address really corresponds to a given key just by hashing it themselves - same idea as SSH's known_hosts / self-certifying identifiers. Nobody, including this relay, can forge an address without the matching private key.
If you'd rather have a human-readable name, send both addr (used as an alias) and pubkey: {"t":"register","addr":"alice","pubkey":"..."}. The first registration of an alias binds it permanently to that key's hash for as long as the relay process stays up. A later attempt to register the same alias from a different key is rejected: {"t":"error","msg":"alias bound to a different key"}. This binding lives only in memory (no database, no persistence across a relay restart) - it stops casual impersonation of an alias while it's in use, it isn't a permanent namespace reservation.
from secure_relay_client import SecureRelayClient
import asyncio
async def main():
client = SecureRelayClient("nowwx7.work", 8443, "alice")
client.on_message = lambda frm, pt: print(frm, "says:", pt.decode())
await client.connect()
asyncio.create_task(client.run())
await client.send("bob", b"hello bob")
asyncio.run(main())
const ws = new WebSocket("wss://nowwx7.work:443"); // or :8444
ws.onopen = () => ws.send(JSON.stringify({ t: "register", addr: "alice" }));
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.t === "deliver") {
// decrypt msg.ct with libsodium.js / tweetnacl-js
console.log("from", msg.from, msg);
}
};
For browsers: libsodium.js or tweetnacl-js match the scheme above. Any library is fine as long as both sides agree on the primitive (X25519 + XChaCha20-Poly1305 / NaCl Box).
last updated by hand, no build step, no framework.