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 | // M4 public-plane e2e — profiles + comments + contact requests over PLAIN
// mailbox replay (no mesh, owner offline):
// A publishes a profile (name/bio/county/category/avatar) → closes.
// B (stranger, fresh device) cold-loads #/u/<A> — renders purely from
// mailbox replay; posts comments; deposits FORGED profile docs for A's
// room (wrong binding + wrong tag) → A's profile unchanged on re-resolve.
// A reopens → sees the comments → hides one + blocks the author → mod doc.
// B re-resolves → comments hidden.
// C requests contact from A's profile (creq) → A accepts (fingerprint
// sheet) → cack → C confirms (fingerprint sheet) → contacts on BOTH sides.
// Run: node e2e/profile.e2e.mjs (HEADED=1 to watch)
// NEVER put a seed (or any part of one) into a fail message — it is logged.
import { deflateSync } from "node:zlib";
import { decode as cborDecode, encode as cborEncode } from "@ipld/dag-cbor";
import {
createHarness,
createIdentity as createIdentityFlow,
fail,
ok,
sleep,
waitContactsReady,
waitQueueDrained,
} from "./helpers.mjs";
const PORT = 5189;
const STUB_PORT = 8796;
const harness = createHarness({ port: PORT, stubPort: STUB_PORT });
const BASE = harness.base;
const openDevice = (label, dir) => harness.openDevice(label, dir);
const createIdentity = (page) => createIdentityFlow(page, BASE);
const roomCount = (roomId) => harness.roomCount(roomId);
const shutdown = () => harness.shutdown();
// ---- fixtures ----------------------------------------------------------------
/** Minimal valid 48×48 solid-color PNG (avatar upload fixture). */
function solidPNG() {
const S = 48;
const crcT = [...Array(256)].map((_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
const crc = (b) => {
let c = 0xffffffff;
for (const x of b) c = crcT[(c ^ x) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
};
const chunk = (t, d) => {
const len = Buffer.alloc(4);
len.writeUInt32BE(d.length);
const td = Buffer.concat([Buffer.from(t), d]);
const cc = Buffer.alloc(4);
cc.writeUInt32BE(crc(td));
return Buffer.concat([len, td, cc]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(S, 0);
ihdr.writeUInt32BE(S, 4);
ihdr[8] = 8;
ihdr[9] = 2; // RGB
const raw = Buffer.alloc(S * (S * 3 + 1));
for (let y = 0; y < S; y++) {
raw[y * (S * 3 + 1)] = 0;
for (let x = 0; x < S; x++) raw.writeUIntBE(0x2f9e6b, y * (S * 3 + 1) + 1 + x * 3, 3);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
/** Derive a public room id inside the page (the exact client code path). */
const publicRoomIdOf = (page, kind, pub) =>
page.evaluate(
async ([k, p]) => {
const rooms = await import("/src/public/public-rooms.ts");
if (k === "profile") return rooms.profileRoom(p);
if (k === "comments") return rooms.commentsRoom(p);
return rooms.inboxRoom(p);
},
[kind, pub],
);
const BIO = "Caut parteneri de drumeții.\nWeekend prin Apuseni.";
/** Deposit `n` junk (non-envelope) blobs straight into a stub room. */
async function seedJunk(roomId, n) {
const url = `http://localhost:${STUB_PORT}/mailbox/v1/rooms/${encodeURIComponent(roomId)}/messages`;
for (let i = 0; i < n; i++) {
const res = await fetch(url, {
method: "POST",
headers: { authorization: `Bearer stub:${roomId}` },
body: `junk-${i}`,
});
if (res.status !== 201) fail(`junk deposit ${i} answered ${res.status}`);
}
}
// ---- scenario ----------------------------------------------------------------
async function main() {
await harness.start();
const dirA = harness.newProfileDir("cp-e2e-pa-");
const dirB = harness.newProfileDir("cp-e2e-pb-");
const dirC = harness.newProfileDir("cp-e2e-pc-");
// --- A: identity + published profile, then leave ----------------------------
let A = await openDevice("A", dirA);
const { fp: fpA } = await createIdentity(A.page);
await waitContactsReady(A.page);
const pubA = await A.page.evaluate(() => window.__cp.identity.identity.publicKeyB64);
ok(`A: identity created (${fpA})`);
// REGRESSION (backward-paging convention, PROTOCOL §6): flood A's
// profileRoom with MORE THAN one page of junk BEFORE the real profile doc
// exists, so the doc ends up strictly NEWEST in the room. Resolving must
// start the backward walk at before=18446744073709551615 (newest window);
// under the old broken convention (first page `before=` empty ⇒ the node
// answers with the OLDEST window) the walk would only ever see the junk
// and the profile below would not render.
const profileRoomA = await publicRoomIdOf(A.page, "profile", pubA);
const JUNK = 110;
await seedJunk(profileRoomA, JUNK);
if ((await roomCount(profileRoomA)) !== JUNK) fail("junk seeding did not land in the stub");
ok(`regression setup: ${JUNK} junk blobs seeded below A's future profile doc`);
await A.page.click('[data-testid="tab-profile"]');
await A.page.waitForSelector('[data-testid="pf-name"]', { timeout: 30000 });
await A.page.fill('[data-testid="pf-name"]', "Ana");
await A.page.fill('[data-testid="pf-bio"]', BIO);
await A.page.selectOption('[data-testid="pf-county"]', "cluj");
await A.page.click('[data-testid="pf-cat"][data-cat="drumetii"]');
await A.page.setInputFiles('[data-testid="pf-file"]', {
name: "avatar.png",
mimeType: "image/png",
buffer: solidPNG(),
});
await A.page.waitForSelector('[data-testid="pf-avatar-preview"] img', { timeout: 15000 });
await A.page.click('[data-testid="pf-publish"]');
await A.page.waitForFunction(
() => document.querySelector('[data-testid="pf-status"]')?.textContent.includes("publicat:"),
undefined,
{ timeout: 30000 },
);
{
// signed profile doc (0x10) + avatar block(s) (0x11) must be in the stub,
// on top of the junk seeded earlier
let count = 0;
for (let i = 0; i < 40 && count < JUNK + 2; i++) {
count = await roomCount(profileRoomA);
if (count < JUNK + 2) await sleep(500);
}
if (count < JUNK + 2) fail(`A's profileRoom holds ${count} blobs, expected ≥${JUNK + 2} (junk + doc + avatar)`);
}
await waitQueueDrained(A.page); // home-log ops (profile-src, settings) flushed
await A.close();
ok("A: profile published (doc + avatar in the mailbox), device closed");
// --- B: stranger cold-load — renders purely from mailbox replay -------------
let B = await openDevice("B", dirB);
await createIdentity(B.page);
await waitContactsReady(B.page);
const pubB = await B.page.evaluate(() => window.__cp.identity.identity.publicKeyB64);
await B.page.goto(`${BASE}/#/u/${pubA}`);
await B.page.waitForSelector('[data-testid="up-name"]', { timeout: 30000 });
const shownName = (await B.page.textContent('[data-testid="up-name"]')).trim();
if (shownName !== "Ana") fail(`profile shows name "${shownName}", expected "Ana"`);
const shownFp = (await B.page.textContent('[data-testid="up-fp"]')).trim();
if (shownFp !== fpA) fail(`profile fingerprint ${shownFp} ≠ A's ${fpA}`);
const shownBio = (await B.page.textContent('[data-testid="up-bio"]')).trim();
if (!shownBio.includes("Apuseni")) fail(`bio not rendered: "${shownBio}"`);
const avatarSrc = await B.page.getAttribute('[data-testid="up-avatar"]', "src");
if (!avatarSrc || !avatarSrc.startsWith("data:image/")) fail("avatar img not rendered from mailbox blocks");
const county = (await B.page.textContent('[data-testid="up-county"]')).trim();
if (county !== "Cluj") fail(`county chip reads "${county}"`);
if (!(await B.page.$('[data-testid="up-cat"][data-cat="drumetii"]'))) fail("category chip missing");
if (await B.page.$('[data-testid="up-stale"]')) fail("fresh mailbox resolve wrongly flagged as cache");
ok("B: cold-load renders A's full profile (name/fp/bio/avatar/chips) from replay alone");
ok(`REGRESSION: resolve found the NEWEST doc above ${JUNK} junk blobs (backward walk from before=max-uint64)`);
// --- B: two comments ---------------------------------------------------------
const commentsRoomA = await publicRoomIdOf(B.page, "comments", pubA);
for (const text of ["Salut! Mergem împreună?", "Și eu sunt din Cluj."]) {
await B.page.fill('[data-testid="comment-input"]', text);
await B.page.click('[data-testid="comment-send"]');
await B.page.waitForFunction(
(t) => [...document.querySelectorAll('[data-testid="comment-item"]')].some((el) => el.textContent.includes(t)),
text,
{ timeout: 30000 },
);
}
if ((await roomCount(commentsRoomA)) !== 2) fail("commentsRoom does not hold exactly the 2 signed comments");
ok("B: posted 2 signed comments — listed after replay");
// --- M1 canonicality: re-encoded variants of a VALID doc are rejected --------
{
// a valid signed comment doc, built by the real client code path
const origB64 = await B.page.evaluate(async (to) => {
const pd = await import("/src/public/public-doc.ts");
const me = window.__cp.identity;
const s = await pd.signDoc(me.identity, "comment", { ts: Date.now(), to, text: "canon" });
return btoa(String.fromCharCode(...s.bytes));
}, pubA);
const orig = Buffer.from(origB64, "base64");
const outer = cborDecode(orig);
// (a) extra outer field, canonically re-encoded: same signed payload → a
// fresh CID for the same logical doc (the dedup-break attack)
const junkField = Buffer.from(cborEncode({ p: outer.p, s: outer.s, d: outer.d, z: 1 }));
// (b) same three fields with the outer map keys PERMUTED by hand
// (p,s,d instead of the canonical d,p,s order)
const permuted = Buffer.concat(
[Uint8Array.of(0xa3), cborEncode("p"), cborEncode(outer.p), cborEncode("s"), cborEncode(outer.s), cborEncode("d"), cborEncode(outer.d)].map((u) => Buffer.from(u)),
);
const verdicts = await B.page.evaluate(async ([o, j, p]) => {
const pd = await import("/src/public/public-doc.ts");
const un = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const accepts = async (b64) => (await pd.verifyDoc(un(b64), { kinds: ["comment"] })) !== null;
return { orig: await accepts(o), junk: await accepts(j), perm: await accepts(p) };
}, [origB64, junkField.toString("base64"), permuted.toString("base64")]);
if (!verdicts.orig) fail("canonicality self-test: the ORIGINAL signed doc failed verification");
if (verdicts.junk) fail("CANONICALITY HOLE: extra-outer-field re-encode was ACCEPTED");
if (verdicts.perm) fail("CANONICALITY HOLE: permuted-key re-encode was ACCEPTED");
ok("canonicality: junk-field + permuted-key re-encodes rejected, original still verifies");
}
// --- B: forged docs for A's profile room are dropped -------------------------
const forged = await B.page.evaluate(async (pubA) => {
const pd = await import("/src/public/public-doc.ts");
const rooms = await import("/src/public/public-rooms.ts");
const io = await import("/src/public/mailbox-io.ts");
const { myXIdentity } = await import("/src/core/contacts.ts");
const me = window.__cp.identity;
const x = await myXIdentity(me.identity, me.seed, "Impostor");
const room = io.publicRoom(await rooms.profileRoom(pubA));
// (a) a VALID profile doc signed by B with a huge seq — binding p≠A must drop it
const doc = {
ts: Date.now(),
seq: 999,
name: "Impostor",
bio: "",
county: "cluj",
categories: [],
xPub: x.xPub,
xSig: x.xSig,
commentPolicy: "open",
};
const s1 = await pd.signDoc(me.identity, "profile", doc);
const r1 = await room.depositDoc(s1);
// (b) a validly signed doc of the WRONG kind (comment tag) in the profile room
const s2 = await pd.signDoc(me.identity, "comment", { ts: Date.now(), to: pubA, text: "x" });
const r2 = await room.depositDoc(s2);
return { ok1: r1.ok, ok2: r2.ok };
}, pubA);
if (!forged.ok1 || !forged.ok2) fail("forged deposits did not land in the stub");
await B.page.reload();
await B.page.waitForSelector('[data-testid="up-name"]', { timeout: 30000 });
const nameAfterForge = (await B.page.textContent('[data-testid="up-name"]')).trim();
if (nameAfterForge !== "Ana") fail(`FORGERY WON: profile shows "${nameAfterForge}" after forged deposits`);
ok("forgery: wrong-binding (seq 999) + wrong-tag docs deposited — profile unchanged on re-resolve");
await B.close();
// --- A reopens: moderation (hide one comment, block the author) --------------
A = await openDevice("A2", dirA);
await A.page.goto(BASE);
await A.page.waitForSelector('[data-testid="tab-profile"]', { timeout: 20000 });
await waitContactsReady(A.page);
await A.page.goto(`${BASE}/#/u/${pubA}`);
await A.page.waitForSelector('[data-testid="comment-item"]', { timeout: 30000 });
await waitContactsReady(A.page); // moderation needs the selfroom (mod-src op)
const nComments = (await A.page.$$('[data-testid="comment-item"]')).length;
if (nComments !== 2) fail(`A sees ${nComments} comments, expected 2`);
if (!(await A.page.$('[data-testid="up-own"]'))) fail("own-profile banner missing on A's own view");
ok("A: reopened, sees B's 2 comments with moderation controls");
await A.page.click('[data-testid="comment-item"] [data-testid="comment-hide"]');
await A.page.waitForFunction(
() => document.querySelectorAll('[data-testid="comment-item"]').length === 1,
undefined,
{ timeout: 30000 },
);
ok("A: hid one comment (bumped mod doc published, list re-resolved)");
await A.page.click('[data-testid="comment-item"] [data-testid="comment-block"]');
await A.page.waitForSelector('[data-testid="up-no-comments"]', { timeout: 30000 });
ok("A: blocked the author — no comments remain visible");
await waitQueueDrained(A.page);
await A.close();
// --- B re-resolves: moderation applies for strangers too ---------------------
B = await openDevice("B2", dirB);
await B.page.goto(`${BASE}/#/u/${pubA}`);
await B.page.waitForSelector('[data-testid="tab-profile"], [data-testid="up-name"]', { timeout: 30000 });
await B.page.waitForSelector('[data-testid="up-no-comments"]', { timeout: 30000 });
if ((await B.page.$$('[data-testid="comment-item"]')).length !== 0)
fail("B still sees moderated comments after the mod doc");
ok("B: re-resolve applies A's mod doc — hidden comment + blocked author filtered");
await B.close();
// --- C: contact request from the profile view --------------------------------
const C = await openDevice("C", dirC);
const { fp: fpC } = await createIdentity(C.page);
await waitContactsReady(C.page);
const pubC = await C.page.evaluate(() => window.__cp.identity.identity.publicKeyB64);
await C.page.goto(`${BASE}/#/u/${pubA}`);
await C.page.waitForSelector('[data-testid="up-creq"]', { timeout: 30000 });
await C.page.click('[data-testid="up-creq"]');
await C.page.waitForSelector('[data-testid="creq-sheet"]');
await C.page.fill('[data-testid="creq-name"]', "Cristi");
await C.page.fill('[data-testid="creq-msg"]', "Hai la drumeții în Apuseni!");
await C.page.click('[data-testid="creq-send"]');
await C.page.waitForSelector('[data-testid="up-creq-sent"]', { timeout: 30000 });
const inboxRoomA = await publicRoomIdOf(C.page, "inbox", pubA);
if ((await roomCount(inboxRoomA)) !== 1) fail("creq did not land in A's inboxRoom");
// duplicate rate-limit: reload the profile — the button must stay "sent"
await C.page.reload();
await C.page.waitForSelector('[data-testid="up-creq-sent"]', { timeout: 30000 });
if (await C.page.$('[data-testid="up-creq"]')) fail("creq button re-armed despite the sent-marker");
await waitQueueDrained(C.page);
await C.close();
ok("C: signed creq deposited into A's inboxRoom (sent-marker blocks duplicates)");
// --- A reopens: request surfaces, fingerprint sheet, accept → cack -----------
A = await openDevice("A3", dirA);
await A.page.goto(BASE);
await A.page.waitForSelector('[data-testid="tab-contacts"]', { timeout: 20000 });
await waitContactsReady(A.page);
await A.page.click('[data-testid="tab-contacts"]');
const rowC = `[data-testid="creq-row"][data-pub="${pubC}"]`;
await A.page.waitForSelector(rowC, { timeout: 60000 });
const rowText = (await A.page.textContent(rowC)).trim();
if (!rowText.includes("Cristi")) fail(`creq row reads "${rowText}" — no requester name`);
if (!rowText.includes("Apuseni")) fail("creq row lacks the request message");
await A.page.waitForFunction(
([sel, fp]) => document.querySelector(sel)?.textContent.includes(fp),
[rowC, fpC],
);
ok("A: incoming contact request listed (fingerprint + name + msg)");
await A.page.click(`${rowC} [data-testid="creq-review"]`);
await A.page.waitForSelector('[data-testid="creq-confirm"]', { timeout: 15000 });
const fpShown = (await A.page.textContent('[data-testid="creq-confirm-fp"]')).trim();
if (fpShown !== fpC) fail(`accept sheet shows ${fpShown}, expected C's ${fpC}`);
await A.page.waitForSelector('[data-testid="creq-accept"]:not([disabled])');
await A.page.click('[data-testid="creq-accept"]');
await A.page.waitForFunction(
(pub) => window.__cp.contacts.state.contacts.get(pub)?.status === "active",
pubC,
{ timeout: 30000 },
);
const inboxRoomC = await publicRoomIdOf(A.page, "inbox", pubC);
{
let count = 0;
for (let i = 0; i < 40 && count !== 1; i++) {
count = await roomCount(inboxRoomC);
if (count !== 1) await sleep(500);
}
if (count !== 1) fail(`cack missing from C's inboxRoom (count=${count})`);
}
await waitQueueDrained(A.page);
await A.close();
ok("A: accepted after fingerprint compare — contact stored + cack deposited");
// --- C reopens: cack confirmation → contacts on both sides -------------------
const C2 = await openDevice("C2", dirC);
await C2.page.goto(BASE);
await C2.page.waitForSelector('[data-testid="cack-confirm"]', { timeout: 90000 });
const fpAck = (await C2.page.textContent('[data-testid="cack-confirm-fp"]')).trim();
if (fpAck !== fpA) fail(`cack sheet shows ${fpAck}, expected A's ${fpA}`);
const ackTitle = (await C2.page.textContent('[data-testid="cack-confirm"] h2')).trim();
if (!ackTitle.includes("Ana")) fail(`cack sheet title "${ackTitle}" lacks A's name`);
await C2.page.click('[data-testid="cack-confirm-yes"]');
await C2.page.waitForFunction(
(pub) => window.__cp.contacts.state.contacts.get(pub)?.status === "active",
pubA,
{ timeout: 30000 },
);
const nameOfA = await C2.page.evaluate((pub) => window.__cp.contacts.state.contacts.get(pub)?.name, pubA);
if (nameOfA !== "Ana") fail(`C stored A as "${nameOfA}"`);
await waitQueueDrained(C2.page);
await C2.close();
ok("C: cack confirmed after fingerprint compare — verified contacts on BOTH sides");
}
let exitCode = 0;
try {
await main();
console.log("\n🎉 public profiles + comments + moderation + creq/cack scenarios passed");
} catch (err) {
console.error("❌ " + (err?.message ?? err));
exitCode = 1;
} finally {
await shutdown();
}
process.exit(exitCode);
|