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
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519 | /**
* Home-log op vocabulary + fold (docs/PROTOCOL.md §4). The home log is the
* private per-identity room every device of one person replicates; folding
* its entries yields the device-shared state: settings now, contacts/groups
* from M2 on. The fold is deliberately generic and forward-compatible:
* unknown op kinds are ignored, so an old client replaying a newer device's
* log keeps everything it understands and drops nothing it doesn't.
*
* LWW discipline: every op kind has a stated key; the entry with the highest
* (ts, tiebreak hash) wins per key. The tiebreak makes concurrent same-ms
* writes from two devices converge deterministically on both.
*/
export type HomeLang = "ro" | "en" | "hu";
export const NAME_MAX = 40;
// C0/C1 controls plus the bidi/zero-width family an attacker could use to
// spoof a display name (U+200B–U+200F, U+202A–U+202E, U+2066–U+2069, U+FEFF).
// eslint-disable-next-line no-control-regex
const NAME_STRIP = /[\u0000-\u001F\u007F-\u009F\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g;
/**
* Canonical display-name form (§2b/§3): strip control/bidi/zero-width chars,
* collapse whitespace runs to one space, trim, clamp to 40 UTF-16 units.
* Signed certs cover exactly this form; verifiers REJECT any wire name that
* is not already its own sanitized form (never normalize-then-verify).
*/
export function sanitizeName(name: string): string {
return name.replace(NAME_STRIP, "").replace(/\s+/g, " ").trim().slice(0, NAME_MAX);
}
/** 43-char unpadded b64url — the only accepted rendering of a 32-byte pub. */
const PUB_RE = /^[A-Za-z0-9_-]{43}$/;
/** 86-char unpadded b64url — a 64-byte Ed25519 signature. */
const SIG_RE = /^[A-Za-z0-9_-]{86}$/;
/** 43-char unpadded b64url — a 32-byte room/group secret. */
const SECRET_RE = /^[A-Za-z0-9_-]{43}$/;
/** 22-char unpadded b64url — a 16-byte public group hint (gidHint, §5b). */
const HINT_RE = /^[A-Za-z0-9_-]{22}$/;
/** Audience epochs are small positive ints (mirrors lib/epoch.ts bounds). */
const goodEpoch = (v: unknown): v is number =>
typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 1_000_000;
/** { t:"settings" } — LWW singleton; grows fields over time (lang M1, name M2). */
export interface SettingsOp {
t: "settings";
ts: number;
lang?: HomeLang;
/** Display name offered on outgoing contact cards (≤40 chars, M2). */
name?: string;
}
/** { t:"contact" } — key: pub (M2 populates these; fold is ready now). */
export interface ContactOp {
t: "contact";
ts: number;
pub: string;
xPub: string;
xSig: string;
name: string;
note?: string;
status: "active" | "removed";
}
/** { t:"group" } — key: gid. */
export interface GroupOp {
t: "group";
ts: number;
gid: string;
name: string;
status: "active" | "removed";
}
/** { t:"gmember" } — key: (gid, pub). */
export interface GMemberOp {
t: "gmember";
ts: number;
gid: string;
pub: string;
on: boolean;
}
/**
* { t:"gkey" } — key: (gid, epoch). One audience epoch secret G_n of MY OWN
* contact group (docs/PROTOCOL.md §4, M5b). The home log is seed-holders-only,
* so the secret rides in plaintext; every device of mine holds every epoch.
* `hint` is the group's PUBLIC identifier (gidHint) embedded in sealed
* public-plane boxes — random and constant across the group's epochs, so
* outsiders can't correlate group structure across users; the gid↔hint
* mapping lives ONLY here.
*/
export interface GKeyOp {
t: "gkey";
ts: number;
gid: string;
epoch: number;
/** 32-byte epoch secret G_n, b64url (43 chars). */
secret: string;
/** 16-byte public gidHint, b64url (22 chars); same for every epoch. */
hint: string;
}
/** One keychain box: E_{n-1} sealed under kChain(n) — lib/epoch.ts shape. */
export interface ChainBox {
iv: string;
ct: string;
}
/**
* { t:"rgrant" } — key: (from, gidHint, epoch). A RECEIVED audience grant
* (§5 pairwise `grant` op) persisted into MY home log so my other devices can
* unseal too. Keyed by the granting contact (`from` = the pair entry's
* VERIFIED author) so one contact can never clobber keys granted by another.
* `chain` maps epoch n → the keychain box that opens E_{n-1} with E_n
* (lib/epoch.ts cascade), unlocking history backward from the granted epoch.
*/
export interface RGrantOp {
t: "rgrant";
ts: number;
from: string;
gidHint: string;
epoch: number;
secret: string;
chain?: Record<string, ChainBox>;
}
/** Chain entries accepted per rgrant (bounds a hostile grant op). */
export const RGRANT_CHAIN_MAX = 512;
/**
* { t:"chatreg" } — key: id. A registered chat room (M3): the room secret +
* display name + member set of a group conversation, shared across all my
* devices via the home log. Created either by me (group creator) or when a
* pairwise `ginvite` op is accepted (core/groupchat.ts).
*/
export interface ChatRegOp {
t: "chatreg";
ts: number;
kind: "group";
id: string;
/** 32-byte room secret, b64url (43 chars). Room = RoomCrypto(secret). */
secret: string;
name: string;
/** All participants' Ed25519 pubs INCLUDING the creator; ≤12 (§4). */
members: string[];
}
/** Group-chat member cap (docs/PROTOCOL.md; enforced at creation + fold + UI). */
export const CHAT_MEMBERS_MAX = 12;
/**
* { t:"profile-src" } — LWW singleton (§4): the FULL profile source of this
* identity (public fields now; restricted plaintext sections in M5), synced
* across devices. The PUBLIC profile doc (§3) is DERIVED from this source at
* publish time; `seq` is the public doc's LWW sequence, bumped per publish.
*/
export interface ProfileSrcOp {
t: "profile-src";
ts: number;
seq: number;
doc: Record<string, unknown>;
}
/**
* { t:"mod-src" } — LWW singleton: moderation source (hidden comment cids +
* blocked author pubs) behind the public mod doc, synced across devices.
* Companion of profile-src (M4 delta over §4's listed vocabulary).
*/
export interface ModSrcOp {
t: "mod-src";
ts: number;
seq: number;
hidden: string[];
blocked: string[];
}
/**
* { t:"mcard" } — LWW singleton per pool (P2): the SOURCE of my matching card
* in one pool ("<category>|<county>"), synced across devices so every device
* re-deposits the same public card doc on app open (§6 re-arm; deterministic
* Ed25519 over identical fields keeps the re-deposit byte-identical ⇒ dedup
* free). `retired` cards stay in the fold and keep re-depositing the
* withdrawal, so the public LWW slot cannot fall back to the older active
* revision when the newest envelope would otherwise age out first.
*/
export interface MCardOp {
t: "mcard";
ts: number;
/** "<category>|<county>" — the same open-vocabulary slug shape as feed shards. */
pool: string;
seq: number;
blurb: string;
/**
* The display name the public doc was SIGNED over at this revision —
* re-seeding compares it with the current profile name and bumps seq on a
* mismatch (re-signing the same (seq, ts) under a new name would break the
* byte-identical-dedup claim). Absent on pre-P2-review sources.
*/
name?: string;
retired?: boolean;
}
/**
* { t:"mlike" } — key: (target, pool) (P2). One like I SENT: the sealed
* dead-drop went to `target`'s inbox; this op keeps what the drop contained
* so every device of mine can (a) detect a mutual match by token intersection
* with received wraps and (b) derive the SAME match room. The home log is
* seed-holders-only, so token/roomSecret ride in plaintext like gkey secrets.
* `name` is the card's display name at like time (match-list rendering on
* devices that never saw the card).
*/
export interface MLikeOp {
t: "mlike";
ts: number;
pool: string;
/** The liked card's author (their Ed25519 pub, b64url). */
target: string;
/** mutualToken for this pair (43-char b64url) — see §2 cp-mutual|v1. */
token: string;
/** 32-byte b64url room secret minted for this like (pubLo's wins, §5c). */
roomSecret: string;
name?: string;
}
export type HomeOp =
| SettingsOp
| ContactOp
| GroupOp
| GMemberOp
| GKeyOp
| RGrantOp
| ChatRegOp
| ProfileSrcOp
| ModSrcOp
| MCardOp
| MLikeOp;
export interface HomeState {
/**
* The winning settings op's RAW fields (everything except the `t`/`ts`
* envelope). Forward-compat: fields this client doesn't know (e.g. a future
* `theme`) are retained verbatim so setSettings() round-trips them instead
* of clobbering them network-wide via the LWW singleton. `lang` and `name`
* are the fields validated for UI use (invalid values are dropped, not
* surfaced).
*/
settings: { lang?: HomeLang; name?: string } & Record<string, unknown>;
contacts: Map<string, ContactOp>;
groups: Map<string, GroupOp>;
gmembers: Map<string, GMemberOp>; // key `${gid}|${pub}`
/** Own group epoch keys (M5b), key `${gid}|${epoch}`. */
gkeys: Map<string, GKeyOp>;
/** Received audience grants (M5b), key `${from}|${gidHint}|${epoch}`. */
rgrants: Map<string, RGrantOp>;
chats: Map<string, ChatRegOp>; // key: chatreg id
/** Winning profile source op (null = never edited a profile). */
profileSrc: ProfileSrcOp | null;
/** Winning moderation source op (null = nothing hidden/blocked yet). */
modSrc: ModSrcOp | null;
/** My matching-card sources (P2), key: pool. */
mcards: Map<string, MCardOp>;
/** Likes I sent (P2), key `${target}|${pool}`. */
mlikes: Map<string, MLikeOp>;
}
export function emptyHomeState(): HomeState {
return {
settings: {},
contacts: new Map(),
groups: new Map(),
gmembers: new Map(),
gkeys: new Map(),
rgrants: new Map(),
chats: new Map(),
profileSrc: null,
modSrc: null,
mcards: new Map(),
mlikes: new Map(),
};
}
/**
* Rgrant keychain shape clamp: a plain map of epoch-number keys (2..1e6) to
* small {iv, ct} boxes (a chain box seals a 32-byte secret: iv 12 B, ct 48 B
* → b64 sizes are tightly bounded). Rejecting here keeps a hostile pairwise
* grant from bloating the fold.
*/
function goodChain(v: unknown): v is Record<string, ChainBox> {
if (!v || typeof v !== "object" || Array.isArray(v)) return false;
const entries = Object.entries(v as Record<string, unknown>);
if (entries.length > RGRANT_CHAIN_MAX) return false;
for (const [k, b] of entries) {
if (!/^[0-9]{1,7}$/.test(k)) return false;
const n = Number(k);
if (!Number.isInteger(n) || n < 2 || n > 1_000_000) return false;
const box = b as Partial<ChainBox> | null;
if (!box || typeof box !== "object") return false;
if (typeof box.iv !== "string" || box.iv.length === 0 || box.iv.length > 24) return false;
if (typeof box.ct !== "string" || box.ct.length === 0 || box.ct.length > 96) return false;
}
return true;
}
/** One folded entry: the decoded op + the entry hash used as the LWW tiebreak. */
export interface HomeEntry {
op: unknown;
hash: string;
}
interface Stamp {
ts: number;
hash: string;
}
/** Incremental fold state: HomeState + per-key LWW stamps. */
export class HomeFold {
readonly state: HomeState = emptyHomeState();
#stamps = new Map<string, Stamp>();
/** ts of the current winning settings op (0 = none) — monotonic stamping. */
get settingsTs(): number {
return this.#stamps.get("settings")?.ts ?? 0;
}
/** Apply one entry; returns true when it won its key (state changed). */
apply(entry: HomeEntry): boolean {
const op = entry.op as Partial<HomeOp> & { t?: unknown };
if (!op || typeof op !== "object" || typeof op.t !== "string") return false;
if (typeof op.ts !== "number" || !Number.isFinite(op.ts)) return false;
// §4 fold enforcement: ops with malformed shapes are REJECTED here, not
// just avoided at creation. gid/pub must never contain "|" (fold keys join
// on it) and pub must be a 43-char b64url pub. Unknown op KINDS are still
// ignored-not-rejected (own-device forward compat with newer vocabularies).
const goodGid = (v: unknown): v is string => typeof v === "string" && v.length > 0 && !v.includes("|");
const goodPub = (v: unknown): v is string => typeof v === "string" && PUB_RE.test(v);
const goodStatus = (v: unknown): v is "active" | "removed" => v === "active" || v === "removed";
// "<category>|<county>" shape (the wire clamp lives in public-doc isShard;
// the fold only needs an unambiguous, bounded key component). Slugs are
// an open vocabulary — lowercase ASCII + digits + hyphens.
const goodPool = (v: unknown): v is string => typeof v === "string" && /^[a-z0-9-]{1,32}\|[a-z0-9-]{1,32}$/.test(v);
let key: string;
switch (op.t) {
case "settings":
key = "settings";
break;
case "contact": {
const c = op as ContactOp;
if (!goodPub(c.pub) || !goodPub(c.xPub) || typeof c.xSig !== "string" || !SIG_RE.test(c.xSig)) return false;
if (typeof c.name !== "string" || !goodStatus(c.status)) return false;
key = `contact|${c.pub}`;
break;
}
case "group": {
const g = op as GroupOp;
if (!goodGid(g.gid) || typeof g.name !== "string" || !goodStatus(g.status)) return false;
key = `group|${g.gid}`;
break;
}
case "gmember": {
const g = op as GMemberOp;
if (!goodGid(g.gid) || !goodPub(g.pub) || typeof g.on !== "boolean") return false;
key = `gmember|${g.gid}|${g.pub}`;
break;
}
case "gkey": {
const g = op as GKeyOp;
if (!goodGid(g.gid) || !goodEpoch(g.epoch)) return false;
if (typeof g.secret !== "string" || !SECRET_RE.test(g.secret)) return false;
if (typeof g.hint !== "string" || !HINT_RE.test(g.hint)) return false;
key = `gkey|${g.gid}|${g.epoch}`;
break;
}
case "rgrant": {
const g = op as RGrantOp;
if (!goodPub(g.from) || !goodEpoch(g.epoch)) return false;
if (typeof g.gidHint !== "string" || !HINT_RE.test(g.gidHint)) return false;
if (typeof g.secret !== "string" || !SECRET_RE.test(g.secret)) return false;
if (g.chain !== undefined && !goodChain(g.chain)) return false;
key = `rgrant|${g.from}|${g.gidHint}|${g.epoch}`;
break;
}
case "chatreg": {
const c = op as ChatRegOp;
// §4 enforcement at the fold: self-minted id (no "|"), a 43-char
// b64url secret, kind we know, and a member set of ≤12 valid pubs.
if (c.kind !== "group" || !goodGid(c.id) || typeof c.name !== "string") return false;
if (typeof c.secret !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(c.secret)) return false;
if (!Array.isArray(c.members) || c.members.length === 0 || c.members.length > CHAT_MEMBERS_MAX) return false;
if (!c.members.every(goodPub)) return false;
key = `chatreg|${c.id}`;
break;
}
case "profile-src": {
const p = op as ProfileSrcOp;
if (!Number.isSafeInteger(p.seq) || p.seq < 1) return false;
if (!p.doc || typeof p.doc !== "object" || Array.isArray(p.doc)) return false;
key = "profile-src";
break;
}
case "mod-src": {
const m = op as ModSrcOp;
if (!Number.isSafeInteger(m.seq) || m.seq < 1) return false;
if (!Array.isArray(m.hidden) || !m.hidden.every((x) => typeof x === "string" && x.length <= 128)) return false;
if (!Array.isArray(m.blocked) || !m.blocked.every(goodPub)) return false;
key = "mod-src";
break;
}
case "mcard": {
const c = op as MCardOp;
if (!goodPool(c.pool) || !Number.isSafeInteger(c.seq) || c.seq < 1) return false;
if (typeof c.blurb !== "string" || c.blurb.length > 300) return false;
if (c.name !== undefined && typeof c.name !== "string") return false;
if (c.retired !== undefined && typeof c.retired !== "boolean") return false;
key = `mcard|${c.pool}`;
break;
}
case "mlike": {
const l = op as MLikeOp;
if (!goodPool(l.pool) || !goodPub(l.target)) return false;
if (typeof l.token !== "string" || !SECRET_RE.test(l.token)) return false;
if (typeof l.roomSecret !== "string" || !SECRET_RE.test(l.roomSecret)) return false;
if (l.name !== undefined && typeof l.name !== "string") return false;
// pub is fixed-width (43 chars), so target|pool is an injective key
key = `mlike|${l.target}|${l.pool}`;
break;
}
default:
return false; // unknown op — a newer client's vocabulary; ignore
}
const prev = this.#stamps.get(key);
if (prev && (prev.ts > op.ts || (prev.ts === op.ts && prev.hash >= entry.hash))) return false;
this.#stamps.set(key, { ts: op.ts, hash: entry.hash });
switch (op.t) {
case "settings": {
// Keep the winning op's raw fields (minus the t/ts envelope) so an
// old client never drops fields a newer client wrote; validate lang
// separately so the UI only ever sees "ro" | "en" | "hu".
const { t: _t, ts: _ts, ...raw } = op as SettingsOp & Record<string, unknown>;
if (raw.lang !== "ro" && raw.lang !== "en" && raw.lang !== "hu") delete raw.lang;
// clamp+strip at the fold: the RETAINED bag stores the sanitized value,
// so writers merging over it can never round-trip a spoofable name
if (typeof raw.name !== "string") delete raw.name;
else raw.name = sanitizeName(raw.name);
this.state.settings = raw as HomeState["settings"];
return true;
}
case "contact": {
const c = op as ContactOp;
this.state.contacts.set(c.pub, { ...c, name: sanitizeName(c.name) });
return true;
}
case "group": {
const g = op as GroupOp;
this.state.groups.set(g.gid, { ...g, name: sanitizeName(g.name) });
return true;
}
case "gmember": {
const g = op as GMemberOp;
this.state.gmembers.set(`${g.gid}|${g.pub}`, g);
return true;
}
case "gkey": {
const g = op as GKeyOp;
this.state.gkeys.set(`${g.gid}|${g.epoch}`, g);
return true;
}
case "rgrant": {
const g = op as RGrantOp;
this.state.rgrants.set(`${g.from}|${g.gidHint}|${g.epoch}`, g);
return true;
}
case "chatreg": {
const c = op as ChatRegOp;
// dedupe members defensively; name sanitized like every display name
this.state.chats.set(c.id, { ...c, name: sanitizeName(c.name), members: [...new Set(c.members)] });
return true;
}
case "profile-src": {
this.state.profileSrc = op as ProfileSrcOp;
return true;
}
case "mod-src": {
const m = op as ModSrcOp;
// clamp at the fold to the public mod-doc caps (§3): 500 / 200
this.state.modSrc = { ...m, hidden: m.hidden.slice(-500), blocked: m.blocked.slice(-200) };
return true;
}
case "mcard": {
const c = op as MCardOp;
this.state.mcards.set(c.pool, {
...c,
...(typeof c.name === "string" ? { name: sanitizeName(c.name) } : {}),
});
return true;
}
case "mlike": {
const l = op as MLikeOp;
this.state.mlikes.set(`${l.target}|${l.pool}`, {
...l,
...(typeof l.name === "string" ? { name: sanitizeName(l.name) } : {}),
});
return true;
}
}
}
}
/** Pure fold over a batch of entries (any order — LWW makes it commutative). */
export function foldHomeLog(entries: Iterable<HomeEntry>): HomeState {
const fold = new HomeFold();
for (const e of entries) fold.apply(e);
return fold.state;
}
|