Encrypted channels
In a channel named private-encrypted-…, every message is encrypted before it leaves the sender
and decrypted by the members. Pikoman relays what it cannot read: the key stays with you.
How it works
-
Your backend has a master key: 32 random bytes in
PIKOMAN_ENCRYPTION_KEY(base64). You make it, and it is never sent to Pikoman. -
For each channel, the backend derives a channel key:
HMAC-SHA256(master key, channel name), 32 bytes. - When a browser asks your token endpoint to join such a channel, the answer carries that channel's key next to the join token. A member gets the keys of its own channels, never the master key.
- Senders encrypt, members decrypt. The server checks the join token as for any private channel, and passes the rest on.
The JavaScript client (@pikoman/ws) and the Rust crate (pikoman) do the encrypting and
decrypting for you. In the browser it is just a private channel:
const pikoman = new Pikoman("your-app-id", { auth_url: "/pikoman/auth" });
const orders = pikoman.listenPrivate("private-encrypted-orders-7");
orders.on("shipped", (order) => showShipped(order)); // already decrypted
orders.refused((code) => console.warn(code)); // "decrypt_failed" for a message that does not decryptThe token endpoint
For a private-encrypted- channel, answer with the channel key as well as the token:
{ "token": "eyJhbGciOiJFZERTQSIs…", "channel_key": "<32 bytes in base64>" }In the Node endpoint from the quickstart, two more lines: derive the key, and add it to the answer.
import { createHmac } from "node:crypto";
// 32 random bytes, made once: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
const masterKey = Buffer.from(process.env.PIKOMAN_ENCRYPTION_KEY, "base64");
/** The key of one channel. Members of that channel get it; nobody gets the master key. */
const channelKey = (channel) => createHmac("sha256", masterKey).update(channel).digest();
// In the request handler, after deciding that this user may join `channel`:
const answer = { token: pikomanToken({ clientId, channel, data: { name: user.name } }) };
if (channel.startsWith("private-encrypted-")) answer.channel_key = channelKey(channel).toString("base64");
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(answer));Keep the master key with your other secrets, apart from your Pikoman keys. Changing it makes old messages unreadable and every open page needs a new channel key, so change it only when you must.
Publishing from your backend
An event's data in an encrypted channel is exactly two strings, both standard base64 with
padding:
{
"nonce": "<12 bytes>",
"ciphertext": "<the encrypted JSON, with the 16-byte tag at the end>"
}- The cipher is AES-256-GCM with the channel key and a fresh random 12-byte nonce for every message.
- The plaintext is the event's real data as UTF-8 JSON.
-
The additional authenticated data is
"<direction>\n<channel>\n<event>"in UTF-8. The direction isserverfor what your backend publishes over the HTTP API andclientfor client events between members; an event without a name uses"". So a message cannot be moved to another channel, passed off as another event, or turned from a member's message into your backend's (or back).
The Rust crate has Rest::publish_encrypted, and @pikoman/ws has
channelKey and encryptEvent helpers for Node. With nothing but
node:crypto:
import { createCipheriv, createHmac, randomBytes } from "node:crypto";
import { pikomanToken } from "./pikoman-token.mjs";
const masterKey = Buffer.from(process.env.PIKOMAN_ENCRYPTION_KEY, "base64");
const channelKey = (channel) => createHmac("sha256", masterKey).update(channel).digest();
function encrypt(channel, event, data) {
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", channelKey(channel), nonce);
cipher.setAAD(Buffer.from(`server\n${channel}\n${event}`)); // "server": published by the backend
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(data)), cipher.final(), cipher.getAuthTag()]);
return { nonce: nonce.toString("base64"), ciphertext: ciphertext.toString("base64") };
}
const channel = "private-encrypted-orders-7";
await fetch(`https://next-api.pikoman.se/${channel}/shipped`, {
method: "POST",
headers: { authorization: `Bearer ${pikomanToken()}`, "content-type": "application/json" },
body: JSON.stringify(encrypt(channel, "shipped", { id: 1042, status: "shipped" })),
});
pikomanToken() is from the HTTP API page. The server
refuses anything else in these channels: an HTTP publish whose body is not {nonce, ciphertext} gets a
400, and such a client event the error code not_encrypted. A bot with an
API key and the master key derives the channel key itself and needs no endpoint.
What it hides, and what it does not
| What | Can Pikoman read it? |
|---|---|
| The data of every event, from your backend and between members | No |
| The master key and the channel keys | No, it never gets them |
| Channel names and event names | Yes |
| Member data in presence channels | Yes |
| The size and time of each message, and which connections talk | Yes |
So name channels so the name says nothing sensitive: private-encrypted-orders-7, not
private-encrypted-ada-lovelace-divorce. Encryption protects against us and anyone who gets into our
servers; it does not protect against a member of the channel, who has the key.
What it does not protect against
- Replay. The relay could deliver the same ciphertext again, and it would decrypt. Put a timestamp or an id in the data and ignore repeats when that matters.
-
Who sent a client event.
fromis set by the relay, not by the cipher. When members must trust the sender, put the sender's identity in the data, and sign it if it matters.
For keys and rights, encrypted channels are private channels: an API key limited to the kind
private reaches them, and a prefix for them starts with private-encrypted-.