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 | // M2 contacts + groups e2e — the requirement proof: two people who are NEVER
// online together exchange verified contact cards through a one-shot
// rendezvous mailbox room (invite link), confirm each other's 4-emoji
// fingerprint, group contacts, and a forged key-change (valid cert, different
// xPub) trips the blocking TOFU warning instead of silently rebinding.
// Devices are isolated persistent profiles (own IndexedDB/localStorage), so
// close/reopen exercises the real multi-visit paths.
// Run: node e2e/contacts.e2e.mjs (HEADED=1 to watch)
// NEVER put a seed (or any part of one) into a fail message — it is logged.
import {
createHarness,
createIdentity as createIdentityFlow,
fail,
ok,
roomIdOf,
sleep,
waitContactsReady,
waitQueueDrained,
} from "./helpers.mjs";
const PORT = 5184;
const STUB_PORT = 8791;
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();
// ---- scenario ----------------------------------------------------------------
async function main() {
await harness.start();
const dirA = harness.newProfileDir("cp-e2e-a-");
const dirB = harness.newProfileDir("cp-e2e-b-");
// --- A: identity, invite creation, deposit, leave ---------------------------
let A = await openDevice("A", dirA);
const { fp: fpA } = await createIdentity(A.page);
ok(`A: identity created, fingerprint ${fpA}`);
await waitContactsReady(A.page);
const pubA = await A.page.evaluate(() => window.__cp.identity.identity.publicKeyB64);
await A.page.click('[data-testid="tab-contacts"]');
await A.page.waitForSelector('[data-testid="invite-create"]');
await A.page.click('[data-testid="invite-create"]');
await A.page.fill('[data-testid="invite-name"]', "Ana");
await A.page.click('[data-testid="invite-name-ok"]');
await A.page.waitForSelector('[data-testid="invite-link"]');
const link = (await A.page.textContent('[data-testid="invite-link"]')).trim();
if (!/^https:\/\/cautpartener\.ro\/#\/add\/[A-Za-z0-9_-]{43}\.[A-Za-z0-9_-]{43}$/.test(link))
fail(`invite link has an unexpected format: ${link}`);
const token = link.split("#/add/")[1];
if (!token.endsWith(`.${pubA}`)) fail("invite link does not carry A's pub");
if (!(await A.page.$('[data-testid="invite-qr"]'))) fail("no QR code in the invite sheet");
ok("A: invite link + QR created (secret.creatorPub format)");
// close the sheet, check the pending-invites section (countdown + cancel)
await A.page.mouse.click(640, 30);
await A.page.waitForSelector('[data-testid="pending-invite"]');
const expiry = (await A.page.textContent('[data-testid="pending-expiry"]')).trim();
if (!expiry) fail("pending invite shows no expiry countdown");
if (!(await A.page.$('[data-testid="pending-cancel"]'))) fail("pending invite has no cancel button");
ok(`A: pending invite listed (${expiry})`);
// the SPECIFIC rendezvous room this invite derives to (not just "some room")
const inviteRoomId = await roomIdOf(A.page, token.split(".")[0]);
{
// A's card must be IN the stub before A leaves: that room = exactly 1 blob
let count = 0;
for (let i = 0; i < 40 && count !== 1; i++) {
count = await roomCount(inviteRoomId);
if (count !== 1) await sleep(500);
}
if (count !== 1) fail(`A's sealed card is not the sole blob in the invite's rendezvous room (count=${count})`);
}
await waitQueueDrained(A.page); // settings op (name) synced too
await A.close();
ok("A: card deposited, device closed — A and B are never co-online");
// --- B: fresh device, accepts A's invite ------------------------------------
let B = await openDevice("B", dirB);
const { fp: fpB } = await createIdentity(B.page);
ok(`B: identity created, fingerprint ${fpB}`);
await waitContactsReady(B.page);
const pubB = await B.page.evaluate(() => window.__cp.identity.identity.publicKeyB64);
await B.page.goto(`${BASE}/#/add/${token}`);
await B.page.waitForSelector('[data-testid="add-fp"]', { timeout: 30000 });
const fpShown = (await B.page.textContent('[data-testid="add-fp"]')).trim();
if (fpShown !== fpA) fail(`compare screen shows ${fpShown}, expected A's ${fpA}`);
const creatorName = (await B.page.textContent('[data-testid="add-name"]')).trim();
if (creatorName !== "Ana") fail(`compare screen shows creator name "${creatorName}", expected "Ana"`);
ok("B: mandatory fingerprint-compare screen shows A's emoji + name");
await B.page.fill('[data-testid="add-my-name"]', "Bogdan");
await B.page.click('[data-testid="add-confirm"]');
await B.page.waitForSelector('[data-testid="add-success"]', { timeout: 30000 });
await B.page.click('[data-testid="add-go-contacts"]');
await B.page.waitForSelector('[data-testid="contacts-panel"]');
const rowA = `[data-testid="contact-row"][data-pub="${pubA}"]`;
await B.page.waitForSelector(rowA);
const rowAText = (await B.page.textContent(rowA)).trim();
if (!rowAText.includes("Ana")) fail(`A's contact row reads "${rowAText}" — no name`);
await B.page.waitForFunction(
([sel, fp]) => document.querySelector(sel)?.textContent.includes(fp),
[rowA, fpA],
);
ok("B: A is a contact (name + fingerprint in the list)");
// contact detail: profile link
await B.page.click(rowA);
await B.page.waitForSelector('[data-testid="detail-link"]');
const profLink = (await B.page.textContent('[data-testid="detail-link"]')).trim();
if (profLink !== `https://cautpartener.ro/#/u/${pubA}`) fail(`profile link is ${profLink}`);
const detailPub = (await B.page.textContent('[data-testid="detail-pub"]')).trim();
if (detailPub !== pubA) fail("detail sheet does not show A's full pub");
ok("B: contact detail carries the full pub + profile link");
await B.page.mouse.click(640, 30); // close sheet
// group "Prieteni": create, then tick it in A's detail sheet
await B.page.click('[data-testid="group-create"]');
await B.page.fill('[data-testid="group-create-input"]', "Prieteni");
await B.page.click('[data-testid="group-create-ok"]');
await B.page.waitForSelector('[data-testid="group-row"]');
await B.page.click(rowA);
await B.page.waitForSelector('[data-testid="detail-group"] input');
await B.page.check('[data-testid="detail-group"] input');
await B.page.mouse.click(640, 30); // close sheet
await B.page.waitForFunction(
(sel) => document.querySelector(sel)?.textContent.includes("Prieteni"),
rowA,
);
ok('B: group "Prieteni" created and A is a member (chip on the row)');
await waitQueueDrained(B.page);
const xPubB = await B.page.evaluate(() => {
// B's own current xPub as A will record it (from B's deposited card)
return import("/src/core/contacts.ts").then(async (m) => {
const id = window.__cp.identity;
const x = await m.myXIdentity(id.identity, id.seed, "Bogdan");
return x.xPub;
});
});
const seedB = await B.page.evaluate(() => localStorage.getItem("cautpartener:id"));
if (!seedB) fail("could not read B's seed for the TOFU forge");
await B.close();
ok("B: home-log ops flushed, device closed");
// --- A reopens: pending invite resolves from B's deposited card -------------
A = await openDevice("A2", dirA);
await A.page.goto(BASE);
await A.page.waitForSelector('[data-testid="invite-confirm"]', { timeout: 90000 });
const fpConfirm = (await A.page.textContent('[data-testid="invite-confirm-fp"]')).trim();
if (fpConfirm !== fpB) fail(`creator confirm sheet shows ${fpConfirm}, expected B's ${fpB}`);
const joinedTitle = (await A.page.textContent('[data-testid="invite-confirm"] h2')).trim();
if (!joinedTitle.includes("Bogdan")) fail(`confirm sheet title "${joinedTitle}" lacks B's name`);
ok("A: boot poll found B's card — fingerprint confirm sheet shown");
await A.page.click('[data-testid="invite-confirm-yes"]');
await A.page.click('[data-testid="tab-contacts"]');
const rowB = `[data-testid="contact-row"][data-pub="${pubB}"]`;
await A.page.waitForSelector(rowB, { timeout: 30000 });
const rowBText = (await A.page.textContent(rowB)).trim();
if (!rowBText.includes("Bogdan")) fail(`B's contact row reads "${rowBText}"`);
const storedXPub = await A.page.evaluate((pub) => window.__cp.contacts.state.contacts.get(pub)?.xPub, pubB);
if (storedXPub !== xPubB) fail("A stored a different xPub than B's certified one");
ok("A: B is a contact — both directions verified, never co-online");
// --- poll-race regression (M6): a fresh boot poll re-reads the invite room
// (B's card is still deposited there) while B is ALREADY an active contact —
// the confirm sheet must NOT re-prompt an already-confirmed card, even if
// the poll starts before the home-log fold catches up.
await A.page.reload();
await A.page.waitForSelector('[data-testid="tab-contacts"]', { timeout: 20000 });
await waitContactsReady(A.page);
await sleep(8000); // boot poll window — enough for fetchCards + prompt path
if (await A.page.$('[data-testid="invite-confirm"]')) fail("boot poll re-prompted an already-confirmed card");
{
const still = await A.page.evaluate((pub) => window.__cp.contacts.state.contacts.get(pub)?.status, pubB);
if (still !== "active") fail("B unexpectedly not active after reload");
}
ok("poll race: fresh boot poll does not re-prompt the already-confirmed card");
// --- TOFU: forged key change (valid cert for B's pub, DIFFERENT xPub) -------
const forged = await A.page.evaluate(async (seed) => {
const { Identity } = await import("/src/lib/identity.ts");
const { deriveXKeyPair } = await import("/src/lib/wrapbox.ts");
const cfg = await import("/src/config.ts");
const { signContactCert } = await import("/src/core/contacts.ts");
const { newInviteSecret, Rendezvous } = await import("/src/core/rendezvous.ts");
const idB = await Identity.fromSeed(seed); // the "attacker" holds B's Ed25519 key
const otherX = await deriveXKeyPair(Identity.newSeed(), cfg.APP_SALT); // NOT B's xPub
const xSig = await signContactCert(idB, otherX.pubB64, "Bogdan"); // §3: name inside the cert
const s = newInviteSecret();
const rdv = await Rendezvous.open(s);
const deposited = await rdv.depositCard({ pub: idB.publicKeyB64, xPub: otherX.pubB64, xSig, name: "Bogdan" });
if (!deposited) throw new Error("forged card deposit failed");
return { secret: s, xPub: otherX.pubB64 };
}, seedB);
const secret2 = forged.secret;
const room2Id = await roomIdOf(A.page, secret2);
ok("TOFU: forged card (valid cert, different xPub) deposited in a fresh invite room");
await A.page.goto(`${BASE}/#/add/${secret2}.${pubB}`);
await A.page.waitForSelector('[data-testid="add-fp"]', { timeout: 30000 });
await A.page.waitForSelector('[data-testid="add-confirm"]:not([disabled])');
await A.page.click('[data-testid="add-confirm"]');
await A.page.waitForSelector('[data-testid="tofu-warning"]', { timeout: 30000 });
ok("TOFU: blocking key-change warning shown (no silent rebind)");
{
const xNow = await A.page.evaluate((pub) => window.__cp.contacts.state.contacts.get(pub)?.xPub, pubB);
if (xNow !== xPubB) fail("contact xPub changed BEFORE the user decided — TOFU violated");
}
await A.page.click('[data-testid="tofu-reject"]');
await A.page.waitForSelector('[data-testid="add-rejected"]');
{
const xNow = await A.page.evaluate((pub) => window.__cp.contacts.state.contacts.get(pub)?.xPub, pubB);
if (xNow !== xPubB) fail("contact xPub changed after REJECT");
// §2b: the invitee deposits their own card only AFTER TOFU passes — the
// rejected room must still hold ONLY the forged card
const c = await roomCount(room2Id);
if (c !== 1) fail(`own card was deposited despite the TOFU reject (room count=${c})`);
}
ok("TOFU: rejected — stored key unchanged, no own-card deposit into the room");
// --- bad-signature card: dropped silently, never prompts --------------------
// Cert signed over a DIFFERENT name than the card carries — the signature
// does not verify against the reconstructed §3 string, so the reader drops
// the card and the flow lands on the honest "not found" state, not compare.
const badSecret = await A.page.evaluate(async (seed) => {
const { Identity } = await import("/src/lib/identity.ts");
const { deriveXKeyPair } = await import("/src/lib/wrapbox.ts");
const cfg = await import("/src/config.ts");
const { signContactCert } = await import("/src/core/contacts.ts");
const { newInviteSecret, Rendezvous } = await import("/src/core/rendezvous.ts");
const idB = await Identity.fromSeed(seed);
const x = await deriveXKeyPair(seed, cfg.APP_SALT);
const xSig = await signContactCert(idB, x.pubB64, "Altcineva"); // name mismatch → invalid sig
const s = newInviteSecret();
const rdv = await Rendezvous.open(s);
if (!(await rdv.depositCard({ pub: idB.publicKeyB64, xPub: x.pubB64, xSig, name: "Bogdan" })))
throw new Error("bad-sig deposit failed");
return s;
}, seedB);
await A.page.goto(`${BASE}/#/add/${badSecret}.${pubB}`);
await A.page.waitForSelector('[data-testid="add-error"]', { timeout: 30000 });
if (await A.page.$('[data-testid="add-compare"]')) fail("bad-signature card reached the compare screen");
ok("bad-signature card dropped silently (honest not-found, no prompt)");
// --- ambiguous room: ≥2 distinct xPubs for one pub → refusal ----------------
const amb = await A.page.evaluate(async () => {
const { Identity } = await import("/src/lib/identity.ts");
const { deriveXKeyPair } = await import("/src/lib/wrapbox.ts");
const cfg = await import("/src/config.ts");
const { signContactCert } = await import("/src/core/contacts.ts");
const { newInviteSecret, Rendezvous } = await import("/src/core/rendezvous.ts");
const idC = await Identity.fromSeed(Identity.newSeed());
const s = newInviteSecret();
const rdv = await Rendezvous.open(s);
for (const seed of [Identity.newSeed(), Identity.newSeed()]) {
const x = await deriveXKeyPair(seed, cfg.APP_SALT);
const xSig = await signContactCert(idC, x.pubB64, "Cristina");
if (!(await rdv.depositCard({ pub: idC.publicKeyB64, xPub: x.pubB64, xSig, name: "Cristina" })))
throw new Error("ambiguous deposit failed");
}
return { secret: s, pub: idC.publicKeyB64 };
});
await A.page.goto(`${BASE}/#/add/${amb.secret}.${amb.pub}`);
await A.page.waitForSelector('[data-testid="add-ambiguous"]', { timeout: 30000 });
if (await A.page.$('[data-testid="add-compare"]')) fail("ambiguous room reached the compare screen");
ok("ambiguous room (2 xPubs for one pub) → honest warning, reader refuses to pick");
// --- invite cancel: tombstoned; a card deposited later never prompts --------
await A.page.goto(`${BASE}/#/`);
await A.page.click('[data-testid="tab-contacts"]');
await A.page.waitForSelector('[data-testid="invite-create"]');
const pendingBefore = (await A.page.$$('[data-testid="pending-invite"]')).length;
await A.page.click('[data-testid="invite-create"]');
await A.page.fill('[data-testid="invite-name"]', "Ana");
await A.page.click('[data-testid="invite-name-ok"]');
await A.page.waitForSelector('[data-testid="invite-link"]');
const link2 = (await A.page.textContent('[data-testid="invite-link"]')).trim();
const cancelSecret = link2.split("#/add/")[1].split(".")[0];
await A.page.mouse.click(640, 30); // close the sheet
await A.page.waitForFunction(
(n) => document.querySelectorAll('[data-testid="pending-invite"]').length === n + 1,
pendingBefore,
);
// cancel it (rows are newest-first, so the first cancel button is this one)
await A.page.click('[data-testid="pending-cancel"]');
await A.page.waitForFunction(
(n) => document.querySelectorAll('[data-testid="pending-invite"]').length === n,
pendingBefore,
);
const intruderPub = await A.page.evaluate(async (s) => {
const { Identity } = await import("/src/lib/identity.ts");
const { deriveXKeyPair } = await import("/src/lib/wrapbox.ts");
const cfg = await import("/src/config.ts");
const { signContactCert } = await import("/src/core/contacts.ts");
const { Rendezvous } = await import("/src/core/rendezvous.ts");
const seed = Identity.newSeed();
const idD = await Identity.fromSeed(seed);
const x = await deriveXKeyPair(seed, cfg.APP_SALT);
const xSig = await signContactCert(idD, x.pubB64, "Intrus");
const rdv = await Rendezvous.open(s);
if (!(await rdv.depositCard({ pub: idD.publicKeyB64, xPub: x.pubB64, xSig, name: "Intrus" })))
throw new Error("intruder deposit failed");
return idD.publicKeyB64;
}, cancelSecret);
// fresh boot poll (page reload resets the poll throttle); the cancelled
// invite is tombstoned so its room is never replayed again
await A.page.reload();
await A.page.waitForSelector('[data-testid="tab-contacts"]', { timeout: 20000 });
await waitContactsReady(A.page);
await sleep(8000);
if (await A.page.$('[data-testid="invite-confirm"]')) fail("cancelled invite still prompted a confirm sheet");
{
const gotIn = await A.page.evaluate((pub) => window.__cp.contacts.state.contacts.has(pub), intruderPub);
if (gotIn) fail("intruder from a cancelled invite became a contact");
}
ok("cancelled invite: tombstoned, later deposit neither prompts nor adds");
// --- TOFU ACCEPT: an explicit accept moves the binding ----------------------
await A.page.goto(`${BASE}/#/add/${secret2}.${pubB}`);
await A.page.waitForSelector('[data-testid="add-fp"]', { timeout: 30000 });
await A.page.waitForSelector('[data-testid="add-confirm"]:not([disabled])');
await A.page.click('[data-testid="add-confirm"]');
await A.page.waitForSelector('[data-testid="tofu-warning"]', { timeout: 30000 });
await A.page.click('[data-testid="tofu-accept"]');
await A.page.waitForSelector('[data-testid="add-success"]', { timeout: 30000 });
{
const xNow = await A.page.evaluate((pub) => window.__cp.contacts.state.contacts.get(pub)?.xPub, pubB);
if (xNow !== forged.xPub) fail("explicit TOFU accept did not move the binding to the new xPub");
// and only NOW does our own card land in the room (forged + ours = 2)
let c = 0;
for (let i = 0; i < 20 && c !== 2; i++) {
c = await roomCount(room2Id);
if (c !== 2) await sleep(250);
}
if (c !== 2) fail(`own card missing after TOFU accept (room count=${c})`);
}
ok("TOFU: explicit accept rebinds the key; own card deposited only after accept");
await waitQueueDrained(A.page);
await A.close();
// --- B reopens: group membership persisted via the home log -----------------
B = await openDevice("B2", dirB);
await B.page.goto(BASE);
await B.page.waitForSelector('[data-testid="tab-contacts"]', { timeout: 20000 });
await B.page.click('[data-testid="tab-contacts"]');
await B.page.waitForSelector(rowA, { timeout: 30000 });
await B.page.waitForFunction(
(sel) => document.querySelector(sel)?.textContent.includes("Prieteni"),
rowA,
);
const groupRowText = (await B.page.textContent('[data-testid="group-row"]')).trim();
if (!groupRowText.includes("Prieteni")) fail(`group row reads "${groupRowText}"`);
ok('B: reopened — group "Prieteni" and A\'s membership persisted via the home log');
await B.close();
}
let exitCode = 0;
try {
await main();
console.log("\n🎉 contacts + groups + rendezvous + TOFU scenarios passed");
} catch (err) {
console.error("❌ " + (err?.message ?? err));
exitCode = 1;
} finally {
await shutdown();
}
process.exit(exitCode);
|