Compare commits
9 commits
4c10e3e724
...
b2fef43607
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2fef43607 | ||
|
|
c944952f04 | ||
|
|
4b3fd4be42 | ||
|
|
bbe5d3d298 | ||
|
|
6b4654231b | ||
|
|
5984d39253 | ||
|
|
975edba88f | ||
|
|
88b5f2473b | ||
|
|
47af393f22 |
9 changed files with 1581 additions and 958 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
|
@ -19,6 +19,7 @@ const { ipcMain } = require("electron");
|
|||
|
||||
const PANEL_METHODS = [
|
||||
"status", "canUseVault", "close", "init", "signOut", "getBackup", "hasBackup",
|
||||
"publishCloudBackup", "fetchCloudBackup", "hasCloudBackup", "removeCloudBackup",
|
||||
"getProfile", "updateProfile",
|
||||
"getPhone", "setPhone", "clearPhone", "findByPhones",
|
||||
"inbox", "send", "markThreadRead",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
|
@ -18,6 +18,26 @@ import {
|
|||
verifyEvent, nip19, nip44,
|
||||
} from "nostr-tools";
|
||||
|
||||
// --- scryptSync maxmem monkey-patch ----------------------------------------
|
||||
// node:crypto defaults scryptSync's maxmem option to 32 MiB. The host-side
|
||||
// mnemonic wrap uses N=2^15 r=8 p=1 which needs ~32 MiB exactly — Node throws
|
||||
// MEMORY_LIMIT_EXCEEDED on the boundary. Lift the ceiling here so the Create
|
||||
// account button stops erroring. Lives in this file (loaded dynamically from
|
||||
// resources/lib/hermes.mjs, outside app.asar) so an installed Theseus can be
|
||||
// hot-patched by replacing this file alone — a main.js fix would be stuck
|
||||
// inside the asar until a new release ships.
|
||||
import { createRequire as _createRequire } from "node:module";
|
||||
const _requireFromHermes = _createRequire(import.meta.url);
|
||||
const _nodeCryptoShared = _requireFromHermes("node:crypto");
|
||||
const _origScryptSync = _nodeCryptoShared.scryptSync;
|
||||
if (!_nodeCryptoShared.__hermesScryptPatched) {
|
||||
_nodeCryptoShared.scryptSync = function (password, salt, keylen, options) {
|
||||
const opts = Object.assign({ maxmem: 128 * 1024 * 1024 }, options || {});
|
||||
return _origScryptSync.call(this, password, salt, keylen, opts);
|
||||
};
|
||||
_nodeCryptoShared.__hermesScryptPatched = true;
|
||||
}
|
||||
|
||||
const enc = new TextEncoder();
|
||||
|
||||
// ---- key derivation --------------------------------------------------------
|
||||
|
|
@ -301,6 +321,23 @@ export async function buildPhoneClaimEvent({ sk, phone } = {}) {
|
|||
}
|
||||
|
||||
/** Deletion event (NIP-09, kind:5) that retracts earlier claim events by id. */
|
||||
// NIP-78 kind:30078 parameterised replaceable event carrying a
|
||||
// password-wrapped mnemonic. d-tag = `hermes.backup.v1/<16-byte hex of
|
||||
// sha256(bareName)>` so a client can locate it from name + password alone.
|
||||
// Content stays opaque to relays — only AES-GCM + scrypt protect the seed.
|
||||
export const BACKUP_DTAG_PREFIX = "hermes.backup.v1/";
|
||||
|
||||
export function buildBackupEvent({ sk, dtag, content } = {}) {
|
||||
if (!sk) throw new Error("sk required");
|
||||
if (!dtag) throw new Error("dtag required");
|
||||
return finalizeEvent({
|
||||
kind: 30078,
|
||||
tags: [["d", dtag]],
|
||||
content: typeof content === "string" ? content : JSON.stringify(content || {}),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
}, sk);
|
||||
}
|
||||
|
||||
export function buildPhoneRetractEvent({ sk, eventIds } = {}) {
|
||||
if (!sk || !Array.isArray(eventIds) || !eventIds.length) throw new Error("sk + eventIds required");
|
||||
return finalizeEvent({
|
||||
|
|
|
|||
141
main.js
141
main.js
|
|
@ -3819,6 +3819,42 @@ function lockPanelView(view) {
|
|||
if (/^(?:https?|bns):/i.test(url)) createTab(url);
|
||||
});
|
||||
}
|
||||
// Right-click menu for sidebar web views (quick-links like WhatsApp /
|
||||
// Telegram, and add-on panels). Sites can override their own context menu
|
||||
// (WhatsApp Web does, and drops "Copy image"); listening at the Electron
|
||||
// layer guarantees a native menu with image / link / copy actions.
|
||||
function installPanelContextMenu(view) {
|
||||
const wc = view.webContents;
|
||||
wc.on("context-menu", (_e, p) => {
|
||||
const items = [];
|
||||
const haveMain = !!win && !win.isDestroyed();
|
||||
if (p.linkURL) {
|
||||
items.push(
|
||||
{ label: "Open link in new tab", enabled: haveMain, click: () => { createTab(p.linkURL); try { win.focus(); } catch {} } },
|
||||
{ label: "Open link in new window", click: () => openLinkWindow(p.linkURL) },
|
||||
{ label: "Copy link address", click: () => clipboard.writeText(p.linkURL) },
|
||||
{ type: "separator" },
|
||||
);
|
||||
}
|
||||
if (p.mediaType === "image" && p.srcURL) {
|
||||
items.push(
|
||||
{ label: "Open image in new tab", enabled: haveMain, click: () => { createTab(p.srcURL); try { win.focus(); } catch {} } },
|
||||
{ label: "Save image as…", click: () => wc.downloadURL(p.srcURL) },
|
||||
{ label: "Copy image", click: () => { try { wc.copyImageAt(p.x, p.y); } catch {} } },
|
||||
{ label: "Copy image address", click: () => clipboard.writeText(p.srcURL) },
|
||||
{ type: "separator" },
|
||||
);
|
||||
}
|
||||
if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" });
|
||||
else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" });
|
||||
items.push(
|
||||
{ label: "Back", enabled: wc.navigationHistory.canGoBack(), click: () => wc.navigationHistory.goBack() },
|
||||
{ label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() },
|
||||
{ label: "Reload", click: () => wc.reload() },
|
||||
);
|
||||
try { Menu.buildFromTemplate(items).popup({ window: win && !win.isDestroyed() ? win : undefined }); } catch {}
|
||||
});
|
||||
}
|
||||
function addonIdForSender(sender) {
|
||||
try {
|
||||
const u = new URL(sender.getURL());
|
||||
|
|
@ -5698,6 +5734,7 @@ function createWindow() {
|
|||
// brand scrollbar the moment its DOM is ready.
|
||||
sidebar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } });
|
||||
lockPanelView(sidebar);
|
||||
installPanelContextMenu(sidebar);
|
||||
raise(sidebar);
|
||||
styleScrollbars(sidebar.webContents);
|
||||
sidebar.setVisible(false);
|
||||
|
|
@ -5727,6 +5764,7 @@ function createWindow() {
|
|||
if (url && /^(?:https?|bns):/i.test(url)) createTab(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
installPanelContextMenu(quickPanel);
|
||||
try { quickPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {}
|
||||
raise(quickPanel);
|
||||
styleScrollbars(quickPanel.webContents);
|
||||
|
|
@ -5734,6 +5772,7 @@ function createWindow() {
|
|||
// Left add-on panels: same preload and IPC surface as the right sidebar.
|
||||
leftPanel = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } });
|
||||
lockPanelView(leftPanel);
|
||||
installPanelContextMenu(leftPanel);
|
||||
try { leftPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {}
|
||||
raise(leftPanel);
|
||||
styleScrollbars(leftPanel.webContents);
|
||||
|
|
@ -10784,6 +10823,108 @@ global._hermesAPI = {
|
|||
},
|
||||
hasBackup: () => hOk({ hasBackup: !!hermesLoadWrappedMnemonic() }),
|
||||
|
||||
// ---- cloud backup (opt-in B) ---------------------------------------------
|
||||
// Publishes the password-wrapped mnemonic as a NIP-78 kind:30078 event
|
||||
// keyed by d-tag = "hermes.backup.v1/<sha256(bareName)>". On a new device
|
||||
// the user enters the same name + password; the client queries relays for
|
||||
// the event, decrypts the wrap, and signs in without ever typing the seed
|
||||
// phrase. Each ciphertext is encrypted with scrypt + aes-256-gcm (same
|
||||
// params as hermesWrapMnemonic) so the password strength IS the backup
|
||||
// strength — relays store opaque bytes.
|
||||
publishCloudBackup: async ({ password } = {}) => {
|
||||
if (!hermesState) return hErr("not initialised");
|
||||
if (!password) return hErr("password required");
|
||||
if (!hermesState.handle) return hErr("this account has no handle set — cloud backup needs one");
|
||||
const localWrap = hermesLoadWrappedMnemonic();
|
||||
if (!localWrap) return hErr("no local wrap to publish — only accounts created with a password can be backed up this way");
|
||||
let mnemonic;
|
||||
try { mnemonic = hermesUnwrapMnemonic(localWrap, password); }
|
||||
catch { return hErr("wrong password — doesn't match the one you set at signup"); }
|
||||
try {
|
||||
const H = await loadHermesLib();
|
||||
const sk = Uint8Array.from(Buffer.from(hermesState.skHex, "hex"));
|
||||
const wrap = hermesWrapMnemonic(mnemonic, password);
|
||||
const bareName = hermesState.handle.toLowerCase().split(".")[0];
|
||||
const nameHash = nodeCrypto.createHash("sha256").update(bareName, "utf8").digest("hex").slice(0, 32);
|
||||
const dtag = H.BACKUP_DTAG_PREFIX + nameHash;
|
||||
const signed = H.buildBackupEvent({
|
||||
sk,
|
||||
dtag,
|
||||
content: { v: 1, handle: hermesState.handle, wrap },
|
||||
});
|
||||
const results = await hermesPublishEvent(signed, HERMES_DEFAULT_RELAYS);
|
||||
const accepted = results.filter((r) => r.ok).length;
|
||||
if (!accepted) return hErr("no relay accepted the backup — try again or add more relays");
|
||||
hermesUpdateIdentityMeta({
|
||||
cloudBackupAt: new Date().toISOString(),
|
||||
cloudBackupEventId: signed.id,
|
||||
});
|
||||
return hOk({ accepted, total: results.length });
|
||||
} catch (e) { return hErr(e); }
|
||||
},
|
||||
|
||||
hasCloudBackup: () => {
|
||||
const meta = hermesLoadIdentityMeta();
|
||||
return hOk({ hasCloudBackup: !!(meta && meta.cloudBackupAt), at: meta && meta.cloudBackupAt || null });
|
||||
},
|
||||
|
||||
removeCloudBackup: async () => {
|
||||
if (!hermesState) return hErr("not initialised");
|
||||
const meta = hermesLoadIdentityMeta();
|
||||
const evId = meta && meta.cloudBackupEventId;
|
||||
if (!evId) {
|
||||
hermesUpdateIdentityMeta({ cloudBackupAt: null, cloudBackupEventId: null });
|
||||
return hOk({ deleted: false, note: "no tracked backup event; cleared local marker" });
|
||||
}
|
||||
try {
|
||||
const H = await loadHermesLib();
|
||||
const sk = Uint8Array.from(Buffer.from(hermesState.skHex, "hex"));
|
||||
const retract = H.buildPhoneRetractEvent({ sk, eventIds: [evId] });
|
||||
await hermesPublishEvent(retract, HERMES_DEFAULT_RELAYS);
|
||||
hermesUpdateIdentityMeta({ cloudBackupAt: null, cloudBackupEventId: null });
|
||||
return hOk({ deleted: true });
|
||||
} catch (e) { return hErr(e); }
|
||||
},
|
||||
|
||||
// Fetch + decrypt cloud backup for sign-in on a new device.
|
||||
// Does NOT touch hermesState — the client calls init() with the returned
|
||||
// mnemonic to actually sign in.
|
||||
fetchCloudBackup: async ({ name, password } = {}) => {
|
||||
if (!name || !password) return hErr("name and password required");
|
||||
try {
|
||||
const bareName = String(name).toLowerCase().split(".")[0].replace(/^hermes:/, "");
|
||||
const nameHash = nodeCrypto.createHash("sha256").update(bareName, "utf8").digest("hex").slice(0, 32);
|
||||
const dtag = "hermes.backup.v1/" + nameHash;
|
||||
const found = await new Promise((resolve) => {
|
||||
let best = null;
|
||||
let pending = HERMES_DEFAULT_RELAYS.length;
|
||||
const finish = () => resolve(best);
|
||||
for (const url of HERMES_DEFAULT_RELAYS) {
|
||||
const ws = new WebSocket(url);
|
||||
const close = () => { try { ws.close(1000); } catch {}; if (--pending <= 0) finish(); };
|
||||
const t = setTimeout(close, 5000);
|
||||
ws.on("open", () => ws.send(JSON.stringify(["REQ", "b-" + Math.random().toString(36).slice(2, 8),
|
||||
{ kinds: [30078], "#d": [dtag], limit: 1 }])));
|
||||
ws.on("message", (buf) => {
|
||||
let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
|
||||
if (msg[0] === "EVENT" && msg[2] && msg[2].kind === 30078) {
|
||||
if (!best || msg[2].created_at > best.created_at) best = msg[2];
|
||||
} else if (msg[0] === "EOSE") { clearTimeout(t); close(); }
|
||||
});
|
||||
ws.on("error", () => { clearTimeout(t); close(); });
|
||||
}
|
||||
});
|
||||
if (!found) return hErr("no cloud backup found for \"" + name + "\"");
|
||||
let payload;
|
||||
try { payload = JSON.parse(found.content); } catch { return hErr("corrupt backup payload"); }
|
||||
if (!payload || payload.v !== 1 || !payload.wrap) return hErr("unsupported backup version");
|
||||
try {
|
||||
const mnemonic = hermesUnwrapMnemonic(payload.wrap, password);
|
||||
return hOk({ mnemonic, handle: payload.handle || null });
|
||||
} catch { return hErr("wrong password"); }
|
||||
} catch (e) { return hErr(e); }
|
||||
},
|
||||
|
||||
// ---- phone discovery (opt-in) --------------------------------------------
|
||||
// Publishes a kind:30078 claim event bound to a hash of the phone number,
|
||||
// signed by the user's Hermes key. The phone number itself is normalised
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "theseus-navigator",
|
||||
"version": "0.3.102",
|
||||
"version": "0.3.103",
|
||||
"license": "MPL-2.0",
|
||||
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
|
||||
"author": "Silent Mode",
|
||||
|
|
|
|||
218
tests/hermes-two-users.test.mjs
Normal file
218
tests/hermes-two-users.test.mjs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
#!/usr/bin/env node
|
||||
// Hermes two-user integration test: Alice and Bob each derive a key from a
|
||||
// mnemonic, publish a phone claim to nos.lol, discover each other by phone,
|
||||
// and round-trip a NIP-17 gift-wrapped DM. Runs against the real production
|
||||
// relay — pass --relay wss://... to point elsewhere.
|
||||
//
|
||||
// Usage: node hermes-two-users-test.mjs
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
import WebSocket from "ws";
|
||||
|
||||
const HERMES = pathToFileURL(path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../lib/hermes.js"
|
||||
)).href;
|
||||
const H = await import(HERMES);
|
||||
|
||||
const RELAY = process.argv.includes("--relay")
|
||||
? process.argv[process.argv.indexOf("--relay") + 1]
|
||||
: "wss://nos.lol";
|
||||
|
||||
const log = (tag, ...msg) => console.log(`[${tag.padEnd(8)}]`, ...msg);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ---- user: mnemonic → nostr key + handle --------------------------------
|
||||
async function makeUser(name, mnemonic, phone) {
|
||||
const { sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic);
|
||||
const phoneHash = await H.hashPhone(phone);
|
||||
log(name, `pk=${pkHex.slice(0, 16)}… phone=${H.normalisePhone(phone)} hash=${phoneHash}`);
|
||||
return { name, sk, pkHex, npub, phone, phoneHash };
|
||||
}
|
||||
|
||||
// ---- relay helpers ------------------------------------------------------
|
||||
function openRelay(label) {
|
||||
const ws = new WebSocket(RELAY);
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
ws.once("open", () => { log(label, `relay connected: ${RELAY}`); resolve(); });
|
||||
ws.once("error", reject);
|
||||
});
|
||||
return { ws, ready };
|
||||
}
|
||||
|
||||
function publish(ws, ev) {
|
||||
return new Promise((resolve) => {
|
||||
const subId = ev.id;
|
||||
const onMsg = (buf) => {
|
||||
let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
|
||||
if (msg[0] === "OK" && msg[1] === subId) {
|
||||
ws.off("message", onMsg);
|
||||
resolve({ ok: !!msg[2], reason: msg[3] || "" });
|
||||
}
|
||||
};
|
||||
ws.on("message", onMsg);
|
||||
ws.send(JSON.stringify(["EVENT", ev]));
|
||||
setTimeout(() => { ws.off("message", onMsg); resolve({ ok: false, reason: "timeout" }); }, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function query(ws, filter, { timeout = 4000 } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const subId = "sub-" + Math.random().toString(36).slice(2, 10);
|
||||
const events = [];
|
||||
const onMsg = (buf) => {
|
||||
let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
|
||||
if (msg[0] === "EVENT" && msg[1] === subId) events.push(msg[2]);
|
||||
else if (msg[0] === "EOSE" && msg[1] === subId) finish();
|
||||
};
|
||||
const finish = () => {
|
||||
ws.off("message", onMsg);
|
||||
try { ws.send(JSON.stringify(["CLOSE", subId])); } catch {}
|
||||
resolve(events);
|
||||
};
|
||||
ws.on("message", onMsg);
|
||||
ws.send(JSON.stringify(["REQ", subId, filter]));
|
||||
setTimeout(finish, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the test -----------------------------------------------------------
|
||||
const ALICE_MNEMONIC = "abandon ability able about above absent absorb abstract absurd abuse access accident";
|
||||
const BOB_MNEMONIC = "zoo yellow year xylophone wrong wrist worth world worry worm wool wood";
|
||||
|
||||
const alice = await makeUser("alice", ALICE_MNEMONIC, "+15550001000");
|
||||
const bob = await makeUser("bob", BOB_MNEMONIC, "+15550002000");
|
||||
|
||||
const relay = openRelay("relay");
|
||||
await relay.ready;
|
||||
|
||||
// --- step 1: both publish phone claims
|
||||
log("step 1", "publishing phone claims…");
|
||||
const aliceClaim = await H.buildPhoneClaimEvent({ sk: alice.sk, phone: alice.phone });
|
||||
const bobClaim = await H.buildPhoneClaimEvent({ sk: bob.sk, phone: bob.phone });
|
||||
const [rA, rB] = await Promise.all([publish(relay.ws, aliceClaim), publish(relay.ws, bobClaim)]);
|
||||
log("alice", `phone claim: ok=${rA.ok} ${rA.reason}`);
|
||||
log("bob", `phone claim: ok=${rB.ok} ${rB.reason}`);
|
||||
|
||||
await sleep(300);
|
||||
|
||||
// --- step 2: Alice looks up Bob by his phone
|
||||
log("step 2", "Alice looking up Bob by phone…");
|
||||
const dtag = H.PHONE_DTAG_PREFIX + bob.phoneHash;
|
||||
const matches = await query(relay.ws, { kinds: [30078], "#d": [dtag], limit: 5 });
|
||||
log("step 2", `found ${matches.length} event(s) for dtag ${dtag}`);
|
||||
if (!matches.length) { log("FAIL", "no phone-claim event came back"); process.exit(1); }
|
||||
const foundPk = matches[0].pubkey;
|
||||
log("step 2", `recovered pubkey: ${foundPk.slice(0, 16)}…`);
|
||||
if (foundPk !== bob.pkHex) {
|
||||
log("FAIL", `wrong pubkey — expected ${bob.pkHex.slice(0, 16)}…, got ${foundPk.slice(0, 16)}…`);
|
||||
process.exit(1);
|
||||
}
|
||||
log("PASS ", "✓ Alice discovered Bob from phone number");
|
||||
|
||||
// --- step 3: Bob also looks up Alice
|
||||
const dtagA = H.PHONE_DTAG_PREFIX + alice.phoneHash;
|
||||
const matchesA = await query(relay.ws, { kinds: [30078], "#d": [dtagA], limit: 5 });
|
||||
if (!matchesA.length || matchesA[0].pubkey !== alice.pkHex) {
|
||||
log("FAIL", "Bob couldn't discover Alice");
|
||||
process.exit(1);
|
||||
}
|
||||
log("PASS ", "✓ Bob discovered Alice from phone number");
|
||||
|
||||
// --- step 4: Alice sends a DM to Bob
|
||||
log("step 4", "Alice sending gift-wrapped DM to Bob…");
|
||||
const greeting = "hey bob, this is alice. If you can read this, nip-17 round-tripped.";
|
||||
const wrap = H.wrapChat({ senderSk: alice.sk, recipientPkHex: bob.pkHex, text: greeting });
|
||||
const rSend = await publish(relay.ws, wrap);
|
||||
log("alice", `wrap publish: ok=${rSend.ok} ${rSend.reason}`);
|
||||
|
||||
// --- step 5: Bob subscribes for incoming wraps, decrypts, verifies sender
|
||||
log("step 5", "Bob listening for his wraps…");
|
||||
const bobWraps = await query(relay.ws, { kinds: [1059], "#p": [bob.pkHex], limit: 10 }, { timeout: 6000 });
|
||||
log("bob", `received ${bobWraps.length} gift-wrap(s)`);
|
||||
let foundOurs = null;
|
||||
for (const w of bobWraps) {
|
||||
try {
|
||||
const dec = H.unwrapChat({ receiverSk: bob.sk, wrap: w });
|
||||
if (dec.text === greeting) { foundOurs = dec; break; }
|
||||
} catch {}
|
||||
}
|
||||
if (!foundOurs) { log("FAIL", "Bob did not receive our greeting"); process.exit(1); }
|
||||
if (foundOurs.senderPkHex !== alice.pkHex) {
|
||||
log("FAIL", `sender mismatch — expected ${alice.pkHex.slice(0, 16)}…, got ${foundOurs.senderPkHex.slice(0, 16)}…`);
|
||||
process.exit(1);
|
||||
}
|
||||
log("PASS ", "✓ Bob decrypted Alice's message and recovered her pubkey");
|
||||
|
||||
// --- step 6: Bob replies, Alice reads
|
||||
log("step 6", "Bob replying…");
|
||||
const reply = "roger that, alice. reply working.";
|
||||
const rWrap = H.wrapChat({ senderSk: bob.sk, recipientPkHex: alice.pkHex, text: reply });
|
||||
await publish(relay.ws, rWrap);
|
||||
|
||||
const aliceWraps = await query(relay.ws, { kinds: [1059], "#p": [alice.pkHex], limit: 10 }, { timeout: 6000 });
|
||||
let foundReply = null;
|
||||
for (const w of aliceWraps) {
|
||||
try {
|
||||
const dec = H.unwrapChat({ receiverSk: alice.sk, wrap: w });
|
||||
if (dec.text === reply) { foundReply = dec; break; }
|
||||
} catch {}
|
||||
}
|
||||
if (!foundReply) { log("FAIL", "Alice did not get Bob's reply"); process.exit(1); }
|
||||
if (foundReply.senderPkHex !== bob.pkHex) {
|
||||
log("FAIL", "reply sender mismatch");
|
||||
process.exit(1);
|
||||
}
|
||||
log("PASS ", "✓ Alice decrypted Bob's reply");
|
||||
|
||||
// --- step 7: Alice imports a vCard listing Bob's phone — exercise the
|
||||
// panel's parse + lookup path end-to-end without the UI.
|
||||
log("step 7", "Alice imports a vCard listing Bob…");
|
||||
const vcard = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Bob Smith
|
||||
TEL;TYPE=CELL:+1 (555) 000-2000
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Someone Else
|
||||
TEL:+15559999999
|
||||
END:VCARD`;
|
||||
const entries = [];
|
||||
for (const block of vcard.split(/BEGIN:VCARD/i).slice(1)) {
|
||||
const body = block.split(/END:VCARD/i)[0] || "";
|
||||
const fn = (body.match(/^FN(?:;[^:]*)?:(.+)$/mi) || [])[1];
|
||||
const tel = (body.match(/^TEL(?:;[^:]*)?:(.+)$/mi) || [])[1];
|
||||
if (tel) entries.push({ name: (fn || "").trim(), phone: tel.trim() });
|
||||
}
|
||||
log("step 7", `parsed ${entries.length} vCard entries`);
|
||||
|
||||
// Mirror findByPhones from main.js, run against the live relay.
|
||||
const phoneMap = new Map();
|
||||
for (const e of entries) {
|
||||
try { phoneMap.set(await H.hashPhone(e.phone), e.phone); } catch {}
|
||||
}
|
||||
const vcDtags = Array.from(phoneMap.keys()).map((h) => H.PHONE_DTAG_PREFIX + h);
|
||||
const vcMatches = await query(relay.ws, { kinds: [30078], "#d": vcDtags, limit: vcDtags.length * 2 }, { timeout: 4000 });
|
||||
const bestByDtag = new Map();
|
||||
for (const r of vcMatches) {
|
||||
const d = (r.tags.find((t) => t[0] === "d") || [])[1];
|
||||
const prev = bestByDtag.get(d);
|
||||
if (!prev || r.created_at > prev.created_at) bestByDtag.set(d, r);
|
||||
}
|
||||
const resolved = [];
|
||||
for (const [dtag, hit] of bestByDtag) {
|
||||
const hash = dtag.slice(H.PHONE_DTAG_PREFIX.length);
|
||||
resolved.push({ phone: phoneMap.get(hash), pkHex: hit.pubkey });
|
||||
}
|
||||
log("step 7", `${resolved.length} vCard entries matched existing Hermes users`);
|
||||
const bobMatch = resolved.find(r => r.pkHex === bob.pkHex);
|
||||
if (!bobMatch) { log("FAIL", "vCard parse → findByPhones did not surface Bob"); process.exit(1); }
|
||||
log("PASS ", `✓ Alice's vCard import auto-discovered Bob (${bobMatch.phone} → ${bobMatch.pkHex.slice(0,16)}…)`);
|
||||
const stranger = resolved.find(r => r.phone.includes("9999999"));
|
||||
if (stranger) { log("FAIL", "stranger phone unexpectedly matched"); process.exit(1); }
|
||||
log("PASS ", "✓ unknown phone number produced no match (as expected)");
|
||||
|
||||
log("DONE ", "all phone-discovery + DM + vCard-import round-trips passed ✓");
|
||||
relay.ws.close();
|
||||
process.exit(0);
|
||||
Loading…
Add table
Reference in a new issue