Encryption and your users
What your app sends through WordPlus Cloud is sealed on the way: the realtime servers carry it without being able to read or change it. You write no encryption code. Your server holds the keys, gives each user the ones they may have, and the browser opens everything before your listener sees it.
What is sealed
| What | Sealed with | Who can open it |
|---|---|---|
| Events your server publishes to a private or presence channel | the channel's key | whoever the channel's owner lets in |
| Client events between browsers on those channels | the channel's key | the same |
A presence member's info | the channel's key | the same |
| What a call's and a room's people see of each other | the day's key | everyone your app knows |
| Your users in the directory (name, avatar …) | the day's key | everyone your app knows |
Public channels are not sealed: anyone with your app's key may listen. The realtime servers see user ids, which key a value needs, and sealed values; they never hold a key.
Your local key
WORDPLUS_LOCAL_KEY, 64 hex characters you make (openssl rand -hex 32), is the master every key is derived from: HMAC-SHA256(local key, "wordplus-realtime key v1|" + label). Keep it:
- the same on every server of the app, and in every language: the Node, PHP and Python packages derive the same keys from it;
- as long as the app: a new one means pages holding old keys can't open what comes, until they reload;
- out of every page and app, as the secret.
It also makes each call's signal key, so WordPlus Cloud can't read a call's negotiation.
Who gets which key
| Key | Label | Given to |
|---|---|---|
| The day's | d:{day} | every user your server knows, yesterday's to tomorrow's |
| A user's own | u:{id}:{generation} | that user |
| A channel's | ch:{channel}:{generation} | whoever the channel's owner lets in, with the channel's token |
| Your own kind | {kind}:{…} | whoever your kind() callback says |
The browser takes keys from your server only (the page's settings, a channel's grant, or your keys route), never from the realtime servers. hasKeys: ( user ) => false cuts a user off.
When someone loses access
- Node
- PHP
- Python
await realtime.rotateChannel( 'private-orders-42' ); // what the channel carries from now on is sealed from them
await realtime.rotateUser( userId ); // their own key moves on
$realtime->rotateChannel( 'private-orders-42' ); // what the channel carries from now on is sealed from them
$realtime->rotateUser( $userId ); // their own key moves on
realtime.rotate_channel("private-orders-42") # what the channel carries from now on is sealed from them
realtime.rotate_user(user_id) # their own key moves on
Rotating needs somewhere to keep the keys' generations, shared by your servers, and kept for good:
- Node
- PHP
- Python
createRealtime( { …, generations: {
get: async ( name ) => Number( await redis.get( 'rt-gen:' + name ) ) || null,
set: async ( name, n ) => void ( await redis.set( 'rt-gen:' + name, n ) ),
} } );
// Laravel: WORDPLUS_REALTIME_GENERATIONS=redis (or database), a cache store that doesn't evict.
final class RedisGenerations implements WordPlus\Realtime\GenerationStore
{
public function __construct( private Redis $redis ) {}
public function get( string $name ): ?int { $n = $this->redis->get( 'rt-gen:' . $name ); return false === $n ? null : (int) $n; }
public function set( string $name, int $generation ): void { $this->redis->set( 'rt-gen:' . $name, $generation ); }
}
$realtime = Realtime::fromEnvironment( [ 'generations' => new RedisGenerations( $redis ), … ] );
class RedisGenerations:
def get(self, name):
n = redis.get("rt-gen:" + name)
return int(n) if n is not None else None
def set(self, name, generation):
redis.set("rt-gen:" + name, generation)
realtime = Realtime.from_environment(generations=RedisGenerations(), …)
Those still let in get the new key from your server when its first event comes.
Your own sealed values
- Node
- PHP
- Python
realtime.kind( 'note', async ( rest, user ) => user !== null && ( await mayRead( user, rest ) ) );
const sealed = await realtime.keys.seal( JSON.stringify( note ), 'note:' + note.id ); // send it any way you like
$realtime->kind( 'note', fn ( $rest, $user ) => null !== $user && may_read( $user, $rest ) );
$sealed = $realtime->keys()->sealJson( $note, 'note:' . $note['id'] ); // send it any way you like
realtime.kind("note", lambda rest, user, label: user is not None and may_read(user, rest))
sealed = realtime.keys.seal_json(note, "note:" + str(note["id"])) # send it any way you like
In the browser:
import { keyring, labelOf } from '@wordplus/realtime';
await keyring().ensure( [ labelOf( sealed ) ] ); // asks your keys route for `note:…` if the page lacks it
const note = keyring().openJson( sealed ); // undefined when this user may not have it
Your users
A directory of your users on WordPlus Cloud, each one's profile sealed with the day's key, shared by every page of your app:
const users = await rt.users( [ 7, 12 ] ); // { 7: { user_id: 7, name: 'Ann', avatar: '…' }, 12: { … } }
A page sends its user's identity, signed by your server with their profile, when it connects; it may then read others'. In this version the directory takes integer user ids, as calls do.