sueta / docs / PROTOCOL.md
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
# sueta p2p protocol — v1

The contract between the browser client (`client/src/lib/`) and the blind signaling
relay (`server/`). Everything here is app-agnostic except the **app salt**, which
namespaces each application built on this stack.

## 1. Identities & secrets

| Thing | Form | Lifetime | Who sees it |
|---|---|---|---|
| room secret | 32 random bytes, base64url unpadded (43 ch) in the URL **fragment** `#<secret>` | as long as the link is shared | only room members (fragments never leave the browser) |
| `roomId` | HKDF(secret, info=`"roomid"`) → 32 bytes → base64url (43 ch) | derived | signaling server (rendezvous only; one-way) |
| `peerId` | 16 fresh random bytes per page load, lowercase hex (32 ch) | one session | everyone in the room + server |
| app salt | UTF-8 string, e.g. `"chat.ardegazu.ro/v1"` | constant per app | baked into the client |

Different apps on the same signaling server use different salts, so identical
secrets can never collide into the same `roomId` across apps.

## 2. Key derivation (WebCrypto)

```
IKM  = importKey("raw", secretBytes, "HKDF")
HK(info, salt=UTF8(appSalt)):
  roomId   = deriveBits(HKDF-SHA-256, info="roomid") -> 32 bytes -> base64url
  kSig(P)  = deriveKey (HKDF-SHA-256, info="signal|"+P) -> AES-GCM-256, non-extractable
  kMsg(P)  = deriveKey (HKDF-SHA-256, info="msg|"+P)    -> AES-GCM-256, non-extractable
```

`kSig(P)` / `kMsg(P)` encrypt traffic **sent by** peer `P`. Receivers derive them
lazily per remote peer and cache them. Because `P` is fresh random per session,
every key is per-sender **and** per-session — no two parties ever encrypt under
the same key, which is what makes the IV scheme below safe.

## 3. Sealing (AES-GCM)

- IV = 12 bytes: `4-byte random epoch (chosen once per Sealer) || 8-byte big-endian counter from 0`.
- A `Sealer` owns (key, epoch, counter). There is **no API that accepts a caller-supplied IV** —
  nonce reuse is structurally impossible.
- Tag length: default 128 bits.

### Envelopes

JSON envelope (signaling payloads and datachannel JSON):

```json
{ "v": 1, "iv": "<b64>", "ct": "<b64, ciphertext||tag>" }
```

Binary envelope (image chunks over the datachannel), one ArrayBuffer:

```
[ 0x01 | 12-byte IV | ciphertext||tag ]
```

### AAD (associated data)

| Channel | AAD |
|---|---|
| signaling payload | `UTF8("v1|" + roomId + "|" + from + "|" + to + "|sig")` |
| datachannel (JSON + binary) | `UTF8("v1|" + roomId + "|" + from + "|msg")` |

AAD binds ciphertexts to room, sender, direction — replay across rooms/peers
fails authentication. Any decryption failure ⇒ drop the message silently.

### Trust note

The server knows `roomId` and could inject fake peers into the roster, but it
cannot produce a decryptable signal (no room secret). Client rule: a peer that
appears in presence but delivers no decryptable signal within **15 s** is
dropped and ignored.

## 4. Signaling protocol (WebSocket, JSON text frames)

Endpoint: `wss://<signal-host>/ws`.

Client → server:

```json
{"t":"join",   "room":"<roomId 43ch b64url>", "peer":"<peerId 32ch hex>"}
{"t":"signal", "to":"<peerId>", "payload":{"v":1,"iv":"...","ct":"..."}}
{"t":"leave"}
{"t":"ping"}
```

Server → client:

```json
{"t":"peers",       "peers":["<peerId>", ...]}
{"t":"peer-joined", "peer":"<peerId>"}
{"t":"peer-left",   "peer":"<peerId>"}
{"t":"signal",      "from":"<peerId>", "payload":{"v":1,"iv":"...","ct":"..."}}
{"t":"pong"}
{"t":"error",       "code":"room-full"|"bad-message"|"rate-limited"|"too-many-rooms"|"not-joined"}
```

Server rules:
- Blind: payloads are opaque; the server never parses, stores, or logs them.
- `signal` is unicast; the server stamps `from` from the joined connection (unspoofable).
- Duplicate `peerId` joining a room replaces the old connection (zombie sockets).
- Empty rooms are deleted. Nothing is persisted, ever.
- Limits (defaults): 500 rooms, 16 peers/room, 64 KiB frame, 30 msg/s burst 60 per conn,
  20 conns/IP, idle reap 60 s (server WS-pings every 25 s).

### TURN credentials

`GET https://<signal-host>/turn-credentials` → coturn REST-API scheme:

```json
{ "username": "<unixtime+3600>",
  "credential": "<base64(HMAC-SHA1(static-auth-secret, username))>",
  "ttl": 3600,
  "urls": ["stun:stun.ardegazu.ro:3478",
           "turn:stun.ardegazu.ro:3478?transport=udp",
           "turn:stun.ardegazu.ro:3478?transport=tcp"] }
```

## 5. Signaling payload plaintext (inside the encrypted envelope)

```ts
{ kind: "offer" | "answer" | "ice",
  sdp?: string,
  candidate?: RTCIceCandidateInit | null,
  name: string }          // sender display name, so rosters fill before channels open
```

## 6. Mesh rules

- Full mesh, one `RTCPeerConnection` per pair. Practical ceiling ~12 peers
  (O(n²) pairs; each message uploads n−1 times).
- **First contact**: the *joining* peer initiates toward every peer in the `peers`
  reply. Existing peers create their pc lazily on first inbound signal. No glare.
- **Renegotiation**: perfect negotiation; politeness = `myPeerId < theirPeerId`
  (string compare). Standard `makingOffer` / `ignoreOffer` guards, implicit
  rollback for the polite peer.
- **Datachannel**: `createDataChannel("chat", { negotiated: true, id: 0 })` on
  both sides — symmetric, immune to glare-duplication.
- **Recovery**: `connectionState "failed"``restartIce()`; still failed after
  10 s with signaling up → teardown and re-initiate by the lexicographically
  smaller peerId. `"disconnected"` → wait.
- Signaling socket loss does **not** touch working channels. On reconnect:
  re-`join`, diff `peers`, connect to new ones only.

## 7. Datachannel payloads (chat app, all under kMsg)

Message id = `"<from>:<seq>"`, per-sender monotonic `seq` from 1.
Ordering: render by `(ts, from, seq)` with stable insert; dedupe by id.

```ts
// text message; thread = id of the root message when replying in a thread
{ kind:"chat", seq, ts, name, text, thread? }

// reaction toggle on any message id
{ kind:"reaction", seq, ts, name, target:"<msgId>", emoji:"👍", op:"add"|"remove" }

// display-name announce/change + lightweight presence.
// idPub/idSig (optional) bind this session to a persistent identity — see §7b.
{ kind:"hello", seq, ts, name, idPub?, idSig? }
{ kind:"name",  seq, ts, name, idPub?, idSig? }

// image transfer: JSON header, then `chunks` binary frames. `tn` is a
// per-sender transfer nonce so concurrent transfers multiplex cleanly.
{ kind:"img", seq, ts, name, tn:<n>, mime:"image/jpeg"|"image/webp",
  bytes:<total plaintext bytes>, chunks:<n>, w:<px>, h:<px>, thread? }

// history sync (see below): request, reply, and image replay into an
// existing placeholder message
{ kind:"hist-req", seq, ts, name }
{ kind:"hist",     seq, ts, name, msgs:[HistMsg...] }   // batches of ≤60
{ kind:"img-hist", seq, ts, name, tn, msgId, mime, bytes, chunks }
```

Binary frames: plaintext chunk = `[4-byte BE transfer nonce | 4-byte BE chunk
index | ≤16 KiB data]`, sealed with the binary envelope. Receivers key
reassembly by (sender, tn). Caps: sender re-encodes to ≤1600 px JPEG/WebP;
hard cap 2 MiB plaintext; receivers discard transfers exceeding declared size.

### History sync (peer-to-peer) + device-local persistence

History lives in the members, never on a server. Each device also persists the
rooms it was in (IndexedDB: last 200 messages + the newest 20 image blobs +
which ids it authored), so returning alone restores your own view — and makes
you a history source for later joiners. A room's history survives as long as
any former member's device still holds it.

- On each channel open a peer sends `hist-req` to one open channel at a time
  (4 s between attempts, each peer asked at most once per session) until a
  non-empty reply arrives — id-dedupe makes overlapping/backfill replies free.
- A member replies with `hist` batches: up to the **last 200** messages
  (id, sender, name, ts, text, thread link, image dimensions, aggregated
  reactions). Ids are original `from:seq`, so overlapping replies from
  several members dedupe cleanly.
- Then it streams the **newest 20 images** as directed `img-hist` transfers
  into the placeholder messages. A non-empty `hist` marks the requester
  synced; an empty one makes it try the next peer.
- Everything travels inside the normal sealed datachannel envelopes — the
  relay sees none of it.

Images are always **re-encoded by the sender** (canvas): HEIC from iPhone
cameras never leaves the device, EXIF (including GPS) is stripped by
construction, orientation is baked in via `createImageBitmap(..., { imageOrientation: "from-image" })`.

## 7b. Persistent identity (Ed25519)

Why: all room members share the room-derived keys, so encryption alone proves
*membership*, not *who* inside the room is talking. Per-session sender
authenticity comes from server-stamped `from` + per-pair DTLS channels, but a
malicious member colluding with a malicious relay could still impersonate
another member's peerId. Identity signatures close that hole and give people
continuity across sessions.

- An identity is a random 32-byte Ed25519 **seed**, base64url (43 chars) —
  small enough to live in a password manager. Keypair derived from it via
  PKCS8 import; stored per browser in `localStorage["sueta:id"]`.
- Each session the client signs a **binding assertion**:
  `sig = Ed25519(seed, UTF8("sueta-id|v1|" + appSalt + "|" + roomId + "|" + peerId))`
  and attaches `idPub` (raw public key, base64url) + `idSig` to `hello`/`name`
  payloads. These travel only inside sealed datachannel envelopes — the relay
  never sees identity keys.
- Receivers verify the assertion (bound to app, room, AND session peerId — it
  cannot be replayed elsewhere) and render a **fingerprint** of the public key:
  first 24 bits of SHA-256(pub) as 4 emoji for casual comparison, full hash in
  hex for careful comparison.
- **TOFU**: the first key seen for a display name is remembered locally; the
  same name later appearing with a different key triggers a loud warning.
- **Verification** is human, out-of-band: compare the 4 emoji (or full hex),
  then mark verified — a ✓ that follows the *key*, stored only on your device.
- No signature / failed verification ⇒ the peer renders identity-less. Nothing
  breaks; there is simply no proof of who they are.

## 7c. Room calls (audio/video)

One call per room, Discord-style. Membership and mic/cam state are ordinary
sealed datachannel payloads:

```ts
{ kind:"call", op:"join"|"state"|"leave", audio:bool, video:bool, seq, ts, name }
```

`join` broadcast on entering; `state` broadcast on every mic/cam change, sent
directed in reply to any `join`, and on `channelOpen` while in-call; `leave`
broadcast on exit. Crash cleanup: peers drop a member on `peerGone` or after
15 s of `disconnected`.

Media rides the SAME per-pair RTCPeerConnections as chat (perfect negotiation
handles the renegotiations; the datachannel is untouched). Tracks are attached
only between pairs where **both** sides announced membership — room members
outside the call exchange zero media. One transceiver per kind per pair,
reused forever; mute/cam-off is `replaceTrack`, full detach flips transceivers
inactive.

Privacy: call media is encrypted per pair by WebRTC's mandatory **DTLS-SRTP**;
the signaling relay and TURN see only encrypted packets. Unlike text (sealed
per sender at the app layer), media has no second app-layer envelope — its E2E
property is per-pair transport encryption. Membership/mute state IS app-layer
sealed like every other message.

Limits: full mesh — every participant uploads to n−1 others. UI warns past 8
in a call and caps cameras at 4; video senders are bitrate-capped (450 kbps,
250 kbps on relayed pairs to stay under coturn's per-allocation cap).

## 8. What the server can and cannot see

Sees: roomIds (opaque 256-bit values), session peerIds, ciphertext sizes/timing, IPs.
Cannot see: room secrets, SDP contents, ICE candidates, names, any message or image bytes.
Stores: nothing. Logs: connection counts only, never payloads.