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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487 | # Caut Partener protocol — delta spec over sueta v2 / board v1
This document specifies everything Caut Partener adds on top of the sueta v2 protocol
(`client/src/lib/` — copied verbatim from board, which is a superset of chat's lib).
Where this document is silent, sueta v2 / board v1 semantics apply unchanged.
Implementation agents code against THIS file; keep it updated in the same change as any
protocol-affecting code.
Constants: `APP_SALT = "cautpartener.ro/v1"`, storage namespace `NS = "cautpartener"`.
All derivations are HKDF-SHA256 with `salt = UTF8(APP_SALT)`, 32-byte output; room ids and
pubkeys render as unpadded base64url (43 chars). Endpoints are feature-detected from
`https://signal.cautpartener.ro/.well-known/ap2p` with baked fallbacks in `src/config.ts`;
mailbox `ttl_hours` / `max_message_kb` are read from the descriptor at boot (RuntimeLimits),
never hardcoded in logic or UI copy.
## 1. Two planes
- **Private plane**: E2E-encrypted rooms using lib's `RoomCrypto`/`RoomLog`/mailbox sync
unchanged. Every private room = a 32-byte secret → deterministic OrbitDB address, sealed
entries, own uncorrelatable mailbox room.
- **Public plane**: NO OrbitDB, NO encryption. Plaintext-signed deterministic dag-cbor
documents in mailbox rooms derived from public data, fetched by plain HTTPS mailbox
replay (no mesh). Rationale: keys derivable from public data would be obfuscation.
## 2. Derivations
Private plane (ikm = listed input):
```
xSeed = HKDF(idSeed, info="id-x25519|v1") board, unchanged
selfSecret = HKDF(idSeed, info="cp-self-room|v1") home log (all my devices)
pairSecret(A,B) = HKDF(X25519(xA,xB), info="cp-pair|v1|<pubLo>|<pubHi>") pubLo/pubHi = the two Ed25519 IDENTITY pubs (b64url), sorted by UTF-16 code units; ikm = raw X25519 shared secret. Consequence: an accepted TOFU key change derives a NEW pair room.
kGroup(g,n) = HKDF(G_n, info="cp-group-op|v1") audience epoch keys (epoch.ts pattern)
inviteSecret = random 32 B one-shot contact rendezvous room
```
Public plane (mailbox room ids; ikm as listed):
```
profileRoom(P) = b64url(HKDF(ikm=rawPubBytes(P), info="cp/mbx/profile|v1"))
commentsRoom(P) = b64url(HKDF(ikm=rawPubBytes(P), info="cp/mbx/comments|v1"))
inboxRoom(P) = b64url(HKDF(ikm=rawPubBytes(P), info="cp/mbx/inbox|v1"))
feedRoom(sh,s) = b64url(HKDF(ikm=UTF8("shard|"+sh+"|s"+s), info="cp/mbx/feed|v1")) s ∈ 0..3
feedTopic(sh) = "cp/1/feed/" + b64url(HKDF(ikm=UTF8("shard|"+sh), info="cp/topic/feed|v1"))
stripe(author) = rawPubBytes(author)[0] & 0x03
shard = "<category>|<countySlug>"
poolRoom(pool) = b64url(HKDF(ikm=UTF8("pool|"+pool), info="cp/mbx/pool|v1")) pool = "<category>|<countySlug>" (P2)
```
Separate rooms per concern on purpose: comment floods must not evict profile docs.
Pool rooms are deliberately UNstriped (one room per pool, v1): cards are tiny,
LWW-collapse to ≤1 per author, and browse walks backward under the same page
budget — striping would buy nothing at v1 scale.
Slug vocabulary (`<category>`, `<countySlug>`): OPEN at the wire, curated in the UI.
Receivers validate slug SHAPE only — `/^[a-z0-9-]{1,24}$/` per component; a shard/pool
is two shape-valid components joined by `|` (≤64 chars total) — never enum membership,
so adding categories/locations is append-only and NEVER a breaking wire change (an
unknown slug from a newer client verifies and renders as its raw slug). Room-binding
checks (`doc.shard`/`doc.pool`/`p` == what the room derives from) stay strict string
equality — they never needed the enum. The curated pickers/labels live client-side:
categories.ts (19 categories, `general` first — the default landing) and geo.ts (44
locations: country-wide `romania` first, then the 41 counties + `bucuresti` +
`diaspora`), giving 19 × 44 = 836 curated shards (and matching pools) at present.
Residual honesty note: clients OLDER than this revision enforced enum membership at
the wire and reject unknown-slug docs wholesale (accepted — pre-launch, no such
clients in the field).
Matching pair token (P2; derivable by exactly the two parties, §5c):
```
mutualToken(A,B) = b64url(HKDF(ikm=X25519(xA,xB), info="cp-mutual|v1|<pubLo>|<pubHi>"))
```
pubLo/pubHi = the two Ed25519 identity pubs sorted as in pairSecret; NOT
pool-scoped — a mutual pair is one match wherever the likes landed.
## 2b. Rendezvous exchange (contact invites)
Invite link `https://cautpartener.ro/#/add/<inviteSecret>.<creatorPub>` (both 43-char
b64url). Room = `mailboxRoomId()` of `RoomCrypto(inviteSecret, APP_SALT)`; payloads =
`mbxCipher()`-sealed `{v:1, kind:"card", card:{pub, xPub, xSig, name≤40}}`. Receiver
rules: decrypt-or-drop → shape clamps (32 B keys, 64 B sig) → §3 cert verify; the invitee
additionally requires `card.pub == creatorPub` from the link. Reader-side dedup per
(pub,xPub), last wins — but if a room holds ≥2 distinct xPubs for the same pub, the reader
REFUSES to pick one and surfaces a warning instead. Receivers clamp/sanitize `name` at the
wire (reject or drop oversized/control-laden names before any display). Mandatory 4-emoji
fingerprint confirmation on BOTH sides before any `contact` op is appended; the invitee
deposits their own card only AFTER passing fingerprint + TOFU decisions. Creator-side
prompt cap: at most 3 confirm prompts per poll, the rest aggregate into a reviewable list.
Multiple invitees per link allowed; the creator persists pending invites locally (cancel
writes a tombstone so another tab's save can't resurrect it) and polls until descriptor
`ttl_hours` expiry. TOFU: a stored key binding for a pub — ANY status, including removed —
never rebinds to a different xPub without an explicit user accept; certs are re-verified at
accept time. The settings bag (§4) additionally carries `name` (sanitized, clamped at the
fold too). Fold-input enforcement: ops whose `gid`/`pub` contain `|` or fail shape checks
are rejected by the fold, not just avoided at creation.
## 3. Public signed documents
```
doc : deterministic dag-cbor map
sigMsg = UTF8(tag + "|" + APP_SALT + "|") || dagCbor(doc)
Signed = dagCbor({ p: <32B raw pub>, s: <64B ed25519 sig>, d: doc })
cid = CIDv1(dag-cbor 0x71, sha2-256(Signed bytes)) — the doc id and dedup key
```
Mailbox envelope prefixes (public plane; disjoint from lib's private 0x01/0x02):
```
0x10 | cid.bytes | Signed bytes signed public doc
0x11 | cid.bytes | block bytes public unixfs block (images)
0x12 | dagCbor(Wrap) sealed dead-drop (matching likes, P2 — §5c; no CID prefix)
```
Receiver rules (mandatory, in this order): sha2-256 multihash only → re-hash bytes against
cid → verify sig with tag for the expected kind → validate fields with clamps (lengths,
slug shapes — §2, ts ≤ now+2 min) → check binding (`p` == pubkey the room derives from,
`shard`/`to` == the room fetched from) → drop silently on any failure.
Doc kinds:
| kind | tag | mutability | fields (beyond pub/ts) |
|---|---|---|---|
| profile | `cp-profile\|v1` | LWW by (seq, ts) | seq, name≤40, bio≤500, county (slug shape §2), categories[] (≤8 unique slug shapes §2), avatar?{cid,mime,w,h,bytes}, xPub, xSig, commentPolicy:"open"\|"off", sealed?:[{gidHint,epoch,box}] |
| mod | `cp-mod\|v1` | LWW by (seq, ts) | seq, hidden:[cid]≤500, blocked:[pub]≤200 |
| post | `cp-post\|v1` | immutable | shard, text≤2000, imgs[{thumbCid,cid,mime,w,h,bytes}]≤4, replyTo?, aud?:{gidHint,epoch} (sealed body in `box` when restricted) |
| comment | `cp-comment\|v1` | immutable | to:<targetPub>, text≤1000, replyTo? (profile comments: to-bound; post comments: replyTo=<postCid>, same shard room) |
| like | `cp-like\|v1` | immutable | re:<postCid>, shard (dedup per author+re at render) |
| creq | `cp-creq\|v1` | immutable | to:<targetPub>, name≤40, msg≤200, xPub, xSig |
| cack | `cp-cack\|v1` | immutable | to:<requesterPub>, xPub, xSig |
| activity (P1) | `cp-activity\|v1` | LWW by (seq, ts, cid) per (pub,aid) | aid (16B b64url, 22 ch), seq, shard, title≤80 (non-empty), desc≤1000, start, end (epoch ms; 0<start<end≤year-2100; publisher additionally requires end>now−7d), loc?≤120 (non-empty), cancelled? (only literal `true`), visibility:"public"\|"group", aud?:{gidHint,epoch}+box? (present together iff "group"; then wire desc MUST be "" and loc MUST be absent — title stays public as the discoverable shell, §5b) |
| join (P1) | `cp-join\|v1` | LWW by (ts, cid) per (pub,org,aid) | org:<organizerPub>, aid, shard, status:"going"\|"out" — binds to (org,aid), NOT an activity cid, so joins survive activity edits; deposited to the joiner's stripe room of the SAME feed shard; joins are PUBLIC metadata even on restricted activities (§7) |
| card (P2) | `cp-card\|v1` | LWW by (seq, ts, cid) per (pub, pool) | pool, seq, blurb≤300 canon, name≤40, xPub, xSig (§3 cert with the LITERAL name, like creq), retired? (only literal `true` — withdraws via the same LWW slot). Binding: `pool` == the poolRoom fetched from; the author binding deliberately does NOT apply (any identity may publish into a pool) |
| head | `cp-head\|v1` | ephemeral (gossip only) | shard, ts (±10 min), cids≤8 — freshness hints on feedTopic; unknown cids trigger a debounced mailbox forward refresh (bitswap deliberately not used; mailbox stays load-bearing) |
Additional receiver rules: docs fetched from `feedRoom(sh,s)` are dropped unless
`stripe(p) == s` (stripe binding, alongside the shard binding). Post permalinks are
`#/p/<urlencoded shard>/<cid>`, resolved by walking the shard's 4 stripe rooms.
Likes are immutable (no un-like) and delete is local-hide + stop-re-seeding only —
both stated in-app. Posts carrying `aud` are rejected by clients that cannot verify
the sealed shape (defined in §5b below).
X25519 cert string (board's pattern, own domain):
`"cp-x25519|v1|" + APP_SALT + "|" + idPub + "|" + xPub + "|" + name`, signed by the
Ed25519 identity. `name` is the sanitized display name (control/bidi chars stripped,
≤40 UTF-16 units, no leading/trailing space) — inside the signature so a link-holder
cannot spoof the display name shown at trust establishment. Verifiers reconstruct the
string from the card's literal fields and REJECT cards whose name is not already in
sanitized form (never normalize-then-verify). idPub/xPub are validated b64url before
string reconstruction, so `|` injection is impossible.
## 3b. Audience-sealed shapes (§5b)
Per contact-group g the owner mints a 32-byte epoch secret G_1 and a random 16-byte public
**gidHint** (both in the `gkey` home op, §4). Restricted content is sealed under
`kGroup(g,n)` (§2) with AES-256-GCM, random 96-bit IV (lib AtRestCipher discipline), and
`AAD = UTF8("cp-aud|v1|<gidHint>|<epoch>|<ownerPub>")` — a box can never be replayed into
another owner's doc, another group, or another epoch. Boxes embed INSIDE public signed
docs: existence public, content opaque.
**Box envelope**: `{v:1, iv, ct}` — base64; iv exactly 12 bytes; ct 16..8192+16 bytes.
**gidHint**: 22-char b64url = 16 bytes. **epoch**: int 1..1 000 000.
**Profile** `sealed?: [{gidHint, epoch, box}]` — ≤8, one per gidHint (dupes reject the
doc). Plaintext = dag-cbor `{v:1, about?≤500, contact?≤200, custom?:[{label≤40,val≤200}]≤8}`.
**Post** `aud?:{gidHint, epoch}` + `box`, present together or not at all. Restricted posts
are **TEXT-ONLY in v1**: wire `text` MUST be `""`, `imgs` MUST be `[]` (receivers reject
otherwise). Plaintext = dag-cbor `{v:1, text≤2000 canonical, non-empty}`. Likes/comments
on restricted posts remain public metadata (§7, stated in-app).
**Activity** (P1) `visibility:"group"` ⇒ `aud` + `box` (same pattern, text-only): the
TITLE stays public (the discoverable shell); plaintext = dag-cbor
`{v:1, desc?≤1000, loc?≤120}` (both canonical; empty strings omitted). Wire `desc` MUST
be `""` and `loc` MUST be absent on group activities (receivers reject otherwise).
Receivers without the key render title + time range + a lock chip. Joins on restricted
activities remain public metadata (§7, stated in-app).
**Convergence rules**: 0x11 block bytes are hash-verified against their envelope CID AT
INGEST (before any block map or cache write) — first-write-wins is only safe verified.
Owner multi-device rekey races (two devices minting the same epoch, or two first-mints
with different gidHints) converge by gkey LWW; a device that detects its own minted key
losing the fold MUST re-grant the winning key to members and re-publish affected sealed
sections. An accepted TOFU key change is treated as remove+re-add for every group the
contact belongs to (rekey + grant over the NEW pair room) — the old key holder may be
compromised. `deleteGroup` triggers a profile republish so the group's sealed section
stops being served immediately. Grant chains are capped at the newest 512 boxes.
**Keychain**: every removal mints G_{n+1} (same gidHint); chain box for epoch n seals
G_{n-1} per lib/epoch.ts with keyring room id `"cp-aud|<gidHint>|<ownerPub>"`
(key = HKDF(G_n, salt=APP_SALT, info="keychain|v1"), AAD
`"v1|cp-aud|<gidHint>|<ownerPub>|keychain|<n>"`). A grant delivers the CURRENT epoch +
chain map `{n:{iv,ct}}` for 2..current; history unlocks by cascading backward. A member
added mid-life gets FULL history access (chosen semantic, matches board). Rekey also
re-seals the owner's current profile sections under the new epoch with a bumped seq, so a
removed member loses the current profile section, not only future edits. Sealed boxes are
computed once per publish and stored in profile-src, so boot republishes stay
byte-identical per seq (fresh IVs would mint new CIDs and defeat §6 dedup).
**Convergence**: the current (highest-epoch) gkey surviving the home-log LWW fold is the
group's canonical key; its gidHint is the group's single public hint. When an owner device
detects a gkey it minted lost LWW to another device's concurrent mint, or that a gid's
folded gkeys disagree on the gidHint, it re-grants the canonical current epoch + full
chain to current members over their pair rooms and re-seals published content under the
canonical key. Devices fold the same winner and converge deterministically. Immutable
content already sealed under a superseded hint is not retracted (§7). An accepted TOFU
key change and a group deletion both trigger the same machinery: key change ⇒ rekey the
contact's groups + re-grant over the NEW pair room; deletion ⇒ immediate profile
republish dropping the group's sealed section.
## 4. Home log ops (selfroom; private; LWW per stated key)
```
{ t:"contact", ts, pub, xPub, xSig, name, note?, status:"active"|"removed" } key: pub
{ t:"group", ts, gid, name, status } key: gid
{ t:"gmember", ts, gid, pub, on } key: (gid,pub)
{ t:"gkey", ts, gid, epoch, secret, hint } key: (gid, epoch); G_n plaintext (seed-holders-only room); hint = gidHint, constant per group; gid↔hint mapping exists ONLY here
{ t:"rgrant", ts, from, gidHint, epoch, secret, chain? } key: (from, gidHint, epoch); a received §5 grant persisted; from = verified pairwise sender (keying by sender means one contact can never clobber another's keys); chain ≤512 {iv≤24,ct≤96}
{ t:"chatreg", ts, kind:"group", id, secret, name, members } key: id
— secret 43-char b64url (32 B); members ≤12 valid pubs INCLUDING the creator; LWW by
id, but ginvite-derived registrations are first-wins (an id never rebinds secrets).
{ t:"profile-src", ts, seq, doc } full profile source incl. restricted plaintext
{ t:"settings", ts, lang?, ... } key: singleton
{ t:"mcard", ts, pool, seq, blurb≤300, retired? } key: pool (P2) — my card SOURCE per pool; every
device re-signs + re-deposits it on app open (deterministic Ed25519 ⇒ byte-identical ⇒ dedup-free);
retired sources KEEP re-depositing so the withdrawal outlives the older active revision
{ t:"mlike", ts, pool, target, token, roomSecret, name? } key: (target, pool) (P2) — one like I SENT
(what the sealed drop contained); token/roomSecret ride plaintext like gkey secrets (seed-holders-only
room); name = the liked card's display name at like time (match-list rendering on synced devices)
```
Constraints: `gid` values are self-minted and MUST NOT contain `|` (fold keys join on it) —
generate them as b64url random ids. The settings singleton is a growing bag: folds MUST
retain unknown fields of the winning op and writers MUST merge their patch over the full
retained set, so old clients never clobber fields they don't understand. Settings writers
use a monotonic ts (`max(now, winner.ts+1)`).
Wipe-detection honesty: the eviction marker is a script cookie; Safari ITP caps script
cookies at ~7 days and evicts them together with site storage, so a total eviction on iOS
presents as "no identity" rather than "wiped". Mitigations: onboarding's create panel
carries an "already used it here? import instead" hint, and a failed
`navigator.storage.persist()` triggers an explicit eviction warning at creation.
## 5. Pairwise room ops (1:1 chat + control channel)
Chat messages use lib's RoomLog entry shapes (text; image/voice as encrypted chunked
blobs — thumb+display for images, single blob ≤60 s for voice). Control ops carried in
the same log: `{ t:"grant", ts, gidHint, epoch, secret, chain? }` (audience key delivery —
deliberately NO gid and NO group name: both are owner-local; members see a generic
"visible to you" badge; receiver rules: shape clamps, sender ≠ self, idempotent),
`{ t:"ginvite", ts, id, secret, name, members }` (group-chat invite —
`members` ≤12 incl. creator is required so the invitee can authorize group-room authors;
receiver rules: shape clamps, first-wins per id, own pub must be in members, AND the
verified pairwise SENDER must be in members — otherwise a contact could hand out a
secret they hold while omitting themselves from the roster, becoming a hidden reader).
Media ops: `{t:"img", ts, thumb:<cid>, full:<cid>, w, h, mime}` (mime ∈ webp/jpeg; thumb
max-edge 256 target ≤40 KB, display max-edge 1080 target ≤300 KB; canvas re-encode strips
all metadata structurally) and `{t:"voice", ts, blob:<cid>, dur 1–60, mime:"audio/*"}`
(MediaRecorder opus/webm preferred, mp4/AAC fallback). Blobs = lib encrypted chunked
unixfs roots (chunk = min((maxMessageKb−1)·1024, 61440)); receivers clamp CID shape,
dims ≤8192, dur, mime, AND bound the actual decode (reject when naturalWidth·naturalHeight
exceeds the cap — op dims clamp only the layout box, not the decode; a small low-complexity
blob can decode huge). Received media roots are pinned locally (mailbox blobs may have no
online source) and unpinned when their message ages out of the fold window.
Room-author authorization: a message renders only when the lib's entry-signature ↔
identity-record binding yields an author in the room's allowed set (pair: the two pubs;
group: the chatreg member set). Room-secret possession alone never renders content.
## 5c. Matching (P2/P3): pool cards, sealed likes, match rooms
Cards (`cp-card|v1`, §3) live in `poolRoom(pool)` and are the opt-in
discoverable "caut partener pentru <categorie> în <județ>". Browse = backward
walk + verify-or-drop (pool binding enforced, author binding N/A), LWW-folded
to ≤1 card per author (the render cap), filtered by the local mod blocklist
and the device-local skip list; ≤200 docs verified per walk. The owner
re-deposits every card source on app open (mcard, §4).
**Like** = `0x12 | dagCbor(Wrap)` deposited into the TARGET's `inboxRoom`.
The Wrap is the lib wrapbox sealed box (HPKE-base shaped) to the target's
CERT-VERIFIED card xPub with `roomId = inboxRoom(target)`:
```
kW = HKDF(X25519(eph, xTarget), salt=APP_SALT, info="wrap|v1|<inboxRoom>|<targetPub>|<ephPub>")
AAD = "v1|<inboxRoom(target)>|wrap|<targetPub>" (board pattern — not replayable elsewhere)
payload (dag-cbor) = { v:1, kind:"like", pool, token: mutualToken(A,B) (§2),
roomSecret: 32 B random b64url, ts }
```
The payload deliberately does NOT name the sender: a received like is
ANONYMOUS until mutual. The liker stores what it sent as an mlike home op
(§4) so all its devices share the sent set.
**Receiver rules** (inbox poll — the creq walk extended): parse 0x12 →
`to == own pub` → wrapbox open with own xPriv → clamp payload (pool slug shape §2,
43-char b64url token/secret, ts ≤ now+2 min, ≤512 B) → store per token, LWW
by ts, capped 500 → drop silently on ANY failure. Unwrap attempts share the
creq walk's 200-envelope verify budget (junk 0x12 is bounded spam, §7).
**Match detection**: mutual ⇔ sent-like tokens ∩ received tokens ≠ ∅,
re-checked on every poll and instantly at like time. The counterpart identity
comes from MY OWN mlike (I know who I liked) — never from the wire. The
anonymous counter ("N persoane te-au apreciat") counts received tokens with
no matching sent like; it names nobody.
**Match room** (P3): both sides hold both payloads at mutual time; the WIRE
pick is the roomSecret from the like SENT BY THE pubLo PARTY (pubLo as in §2
pairSecret ordering) — deterministic on both sides with no negotiation. That
peer-chosen value is NEVER fed to the hub directly; both sides derive
```
effectiveSecret = b64url(HKDF(ikm = rawBytes(roomSecret), salt = UTF8(APP_SALT),
info = "cp-match-room|v1|" + token))
```
and open the room with THAT. The token is pair-private and full-entropy, so
the derived secret is unique per pair (a replayed or attacker-chosen wire
secret cannot collide two match rooms) and carries a 256-bit entropy floor
regardless of the wire value. The derived secret feeds the standard RoomHub
private-room stack (conversation id `m~<peerPub>`; render authorization = the
two matched pubs) — a private E2E conversation WITHOUT contact exchange. Contact upgrade is optional and runs
INSIDE the match room: `{ t:"card", ts, card:{pub,xPub,xSig,name} }` control
ops, verified like rendezvous cards (shape clamps, `card.pub` == the entry's
verified author, §3 cert) + the MANDATORY fingerprint sheet before
addContact (TOFU rules apply). Likes have no undo; skips are local-only.
## 5d. Calls (P4): 1:1 audio/video over pair + match rooms
Calls are SYNCHRONOUS — both parties must be on the live mesh; the mailbox
plays no part except the durable marker op below. 1:1 only (pair + match
conversations); group calls are out of scope in v1 (honest limit, §7).
**Media** never rides libp2p: each call runs ONE dedicated media-only
`RTCPeerConnection` (DTLS-SRTP, own ICE/TURN via lib/turn.ts, video sender
capped 450 kbps) — the vendored lib/media.ts machinery: perfect negotiation
(politeness = lexicographically smaller session pid), one transceiver per
kind reused forever, mute = `replaceTrack(null)`, `failed` → `restartIce()`
then deterministic rebuild by the smaller pid. An established call keeps
flowing if the relay dies; signaling is only needed to (re)negotiate.
**Signaling channel** (delta from upstream chat, where a per-room lib Net
carries kSig frames on directed `/sueta/2/call-sig/1.0` streams): our hub is
ONE shared node for all rooms with no per-room network identity, so call
signaling rides the room's gossipsub topic instead — `RoomCrypto.roomTopic`,
the lib's own "ephemeral traffic (presence, calls)" derivation, one-way from
the room secret and opaque to the relay. Frames are JSON:
```
{v:1, k:"ctl", f:<pid>, e:<Envelope>} kMsg-sealed (sealMsg/openMsg)
{v:1, k:"sig", f:<pid>, to:<pid>, c:<callId>, kSig-sealed (sealSignal/openSignal,
d:<env json>} produced inside lib/media.ts)
```
`pid` = the per-room per-session RoomCrypto peer id. The plaintext `f`/`to`
are routing hints only: both AADs (`v1|roomId|from|msg`, `v1|roomId|from|to|sig`)
bind them, so a spoofed header fails to decrypt and drops silently. In a 1:1
room decryptability IS membership (only the two identities can derive the
keys); own-device frames are told apart by the sealed `pub` field. The sig
frames' plaintext `c` is the call's random callId, stamped at the ADAPTER
layer (lib/media.ts is untouched): receivers drop sigs whose `c` is not the
active call's, so a replayed sealed SDP/ICE frame from an earlier call (or a
cross-session replay by the relay operator) cannot enter negotiation. `c` is
a per-call random UUID — the plaintext hint leaks no identity beyond what
the topic itself already groups.
**Control payloads** (kMsg-sealed): `{kind:"call-ctl", op, callId, ts, pub?,
video?, busy?}` with op ∈ invite | cancel | accept | decline | hangup.
Receiver clamps: callId ≤64, pub ≤64, drop non-decrypting, and drop frames
whose sealed `ts` is outside |now−ts| ≤ 120 s — a relay operator can replay
sealed frames verbatim, and the freshness clamp kills the replayed-invite
ghost ring (finished callIds are additionally deduped). HONEST RESIDUAL: a
relay operator can still replay ctl/sig frames WITHIN the freshness window /
within the same live call — callId dedupe and media.ts perfect negotiation
make those no-ops or at worst a transient renegotiation (bounded DoS, same
power as dropping frames, which a relay can always do). Invites repeat
every 2 s while ringing (gossip is best-effort; the repeat doubles as the
ring keep-alive — a callee whose invites stop arriving for >6.5 s dismisses).
Ring timeout 45 s both sides. Invites/accepts carry the sender's identity
pub: a device ignores invites with `pub == own` (own other device dialing
out) and stops ringing when it sees an accept with `pub == own` (answered
elsewhere); a caller only honors accepts whose `pub` equals the
conversation's peer. Sealed SDP frames that arrive before the local media pc
exists are buffered (bounded) and flushed — dropping them can deadlock
negotiation. Names NEVER ride the wire; the UI shows local contact/match
names only.
**Marker op** (§5 room log, appended by the CALLER only, at most one per
call): `{t:"call", ts, kind:"missed"|"ended", dur?}` — dur in seconds, ended
only. missed = the call NEVER reached connected, however it died: ring
timeout, caller gave up, connect timeout, or a manual hangup while still
"connecting"; ended = a connected call finished (either side hung up — the
caller's device appends). Fold clamps: kind enum, dur 0..86400, ts clamped like every chat
op. Renders as a system line; an offline peer sees a missed call via normal
mailbox replay.
Transport: ONE shared browser libp2p node for all rooms (webSockets + webRTC + circuit
relay; gossipsub topics = OrbitDB addresses, opaque to the relay). Live replication is an
ENHANCEMENT — the mailbox path is load-bearing and must work with the live node off.
Live node starts lazily, hangs up after >5 min hidden, redials with backoff on wake.
Presence = gossipsub subscribers of the room topic (caveat: own second device counts).
## 6. Behavior contracts
- **Profile publish**: owner bumps seq, deposits to profileRoom; re-deposits current
profile + mod + avatar blocks on every app open (dedup free; re-arms TTL).
- **Profile resolve**: page mailbox BACKWARD (`before=`), keep highest valid (seq,ts)
profile+mod; page budget 20; over budget ⇒ "profil indisponibil — posibil spam".
- **Backward paging, empirically verified against the live node 2026-08-22**:
`before=<seq>` returns the NEWEST `limit` messages with seq < before, ascending,
`has_more` = older remain. No params or empty `before` returns the OLDEST window
(forward), NOT the newest. The first backward page MUST use the constant
`before=18446744073709551615` (max uint64 — accepted by the node, returns the newest
window including the latest message). The e2e mailbox stub mirrors these exact
semantics. Never parse seqs client-side; the constant is the only literal cursor.
- **Feed browse**: replay 4 stripes backward, merge order (ts, cid); forward cursors per
stripe for refresh; live feedTopic joined with probability min(1, 12/estimatedSubs);
topic messages = `{kind:"head", cids:[≤8]}` plaintext-signed announcements. Shard
vocabulary is open at the wire (§2); the UI curates 19 categories × 44 locations =
836 shards, default landing `general|romania` (or `general|<profile county>`).
- **Re-seed** after replay: own docs always + 5 random cached foreign docs older than 24 h
(+ their blocks), jittered 0–60 s, ≤10 deposits/room/visit.
- **Render caps**: ≤20 posts/author/shard/day, ≤10 comments/author/profile/day; local
blocklists; owner mod docs. Reserved optional `stamp` (hashcash over cid) — ignored v1.
- **Activities (P1)** ride the EXISTING feed shard rooms, kind-filtered — no new room
derivations. Activity docs deposit to the organizer's stripe; join docs to the joiner's
stripe of the same shard; both are shard- and stripe-bound like posts/likes. The feed
browse folds a client-side UPCOMING index (end ≥ now, sorted by start ASC, ≤20 per
organizer). Organizer edit/cancel = republish the SAME aid with a bumped seq (cancel
sets `cancelled:true`); the superseding publish drops the superseded envelope from the
local re-seed cache. EXPIRED activities (end < now − 24 h) — and their joins — are
excluded from re-seed sampling (cache `exp` hint), so they age out of the public window
instead of living forever. Activity permalinks are `#/a/<urlencoded shard>/<org>/<aid>`
(the shard is part of the link — org+aid alone cannot derive a room), resolved by
walking the shard's 4 stripe rooms like post permalinks.
- **Contacts-only audience (P1, v1.1 approach)**: the implicit "toate contactele" group is
a REAL contact group with the CONSTANT self-minted gid `toate-contactele` (one LWW slot
per §4 ⇒ devices converge; gkey mint races settle via §3b convergence), lazily created
at the first contacts-only publish and AUTO-MAINTAINED by the contacts service: every
contact add (incl. an accepted TOFU key change) auto-enrolls (grant = full-history
access), every contact remove auto-unenrolls first (gmember off + rekey via the
membership hook; the generic contact-removal rekey then skips it). Honest difference
from a hand-made group, shipped in-app: membership follows the contact book, later
contacts see earlier contacts-only content, removed contacts keep what they already
hold (§7).
- **Matching (P2)**: cards re-deposit on every app open (mcard sources, retired included —
the withdrawal must outlive the older active revision in the window); pool browse walks
backward under the standard page budget with ≤200 docs verified; the inbox poll (creq
walk) shares its 200-envelope budget with 0x12 unwrap attempts; likes/skips: no undo,
skips local-only; foreign cards are NOT re-seeded by visitors in v1 (a card is the
owner's own liveness signal — an absent owner SHOULD age out of the pool).
- **Moderation**: mod-doc hiding + owner never re-seeds hidden content. Non-retractive.
- **Gap honesty**: cursor < oldest_seq ⇒ show "history truncated", never silence.
- **Closed-room unread probe**: one mailbox replay(limit=1) reads latest_seq; unread ⇔
latest > cursor AND latest > last own deposit. Honest limit: a peer deposit sandwiched
between two of ours can read "seen" until the next real replay.
- **Room budget**: ≤6 concurrently open OrbitDB stores, self room pinned, LRU eviction
with graceful close; deposit queues persist so eviction loses nothing.
- **Mailbox token strategy**: ONE app-scoped open-mint token (`?ttl=86400`, no `room`
param — verified against the live node 2026-08-22), cached in memory + localStorage to
its ACTUAL returned `expires_at` and shared by all rooms/tabs; per-room scoping adds no
security under open mint (the room id is the guard) and only multiplies billed mints.
## 7. Honest limits (must ship in-app, RO+EN)
Public window = descriptor ttl_hours (168 h) kept alive by visitor re-seeding; feeds are
best-effort, not globally consistent; removal/moderation is non-retractive; open mailbox
rooms ⇒ client-side-only spam defense; the node operator can read public plaintext and map
public room ids to meaning (private rooms stay opaque: the node sees only sizes/timing);
identities are free (Sybil) — trust = fingerprints + TOFU + verified contacts; no forward
secrecy in pairwise chat or within a group epoch; removal ≠ retraction. Activities (P1):
joins (who's going) are public metadata even on group-restricted activities — only the
description/location are sealed, the title and time range stay public; "contacts only" is
an auto-maintained group, so a later contact gains access to earlier contacts-only
activities and a removed contact keeps everything already received. Matching (P2/P3): pool
cards are PUBLIC by construction (that is their point); a sealed like hides the sender from
everyone INCLUDING the target until mutual, BUT (a) the target can dictionary-test received
tokens against candidates it can enumerate — one X25519 per browsable card's xPub confirms
or refutes "did THIS person like me" (inherent to any scheme where both sides compute the
token from public keys); (b) the node operator sees inbox deposit timing/sizes and can
correlate them with pool browsing; (c) inbox rooms are open, so junk 0x12 costs the
receiver only its bounded per-poll budget; (d) a sent like cannot be withdrawn (the sealed
drop is out) and skips are device-local only; (e) the anonymous received-likes count counts
unmatched tokens, not people — it shrinks when a like becomes a match, and because anyone
can mint tokens and deposit clamp-passing wraps, the count can be INFLATED by junk (stated
in-app next to the counter); (f) the match-room key is derived from material minted by the
pubLo party — the confidentiality of a match room equals trust in the counterparty, exactly
like any DM (the other side can always leak); (g) the node operator can infer that a match
happened from co-join timing: two parties starting to poll/deposit in a fresh mailbox room
shortly after inbox activity. Calls (P4): 1:1 only — no group calls in v1; calls need BOTH
parties online simultaneously (no voicemail; a missed call leaves only a room-log marker);
media is P2P DTLS-SRTP — a TURN relay, when the path needs one, relays only ciphertext it
cannot read BUT sees both parties' IPs, the call's timing and byte volume (and the relay
operator can correlate a call with the room's gossip activity); ring signaling rides
best-effort gossip — a ring can be missed with no trace; the browser cannot switch
earpiece/speaker routing (the OS decides), so there is no such toggle.
|