Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap, name-addressed send/receive verified end-to-end via public relay - Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth so the registrar can compute np without pulling nostr-tools into Argus - Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes np (Nostr pubkey) and nr (relay) records for a chipnet name - TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record parsing, canonical for Theseus; guarded by lib/package.json type:module - TheseusNavigator/messages.html + messages-preload.js: Messages panel UI (identity from mnemonic, live inbox, compose by .bch name), .bch suffix stripped from displayed names - TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD; ipc handlers (hermes-status/init/close/inbox/send/open), per-relay subscription with auto-reconnect, status pushed on WS open/close, reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut via web-contents-created (works from any tab) - Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol (txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b); end-to-end verified in Theseus
This commit is contained in:
parent
bf996bdb0c
commit
7bd4b2b1c3
7 changed files with 775 additions and 4 deletions
162
lib/hermes.js
Normal file
162
lib/hermes.js
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// Hermes: name-addressed NIP-17 messaging over Nostr.
|
||||
//
|
||||
// This module lives inside Theseus because it depends on `nostr-tools` (a
|
||||
// non-trivial dep the shared library layer, Argus, shouldn't carry). Loaded
|
||||
// from main.js via dynamic import — same pattern as password-vault.js.
|
||||
//
|
||||
// Layers, sender → wire (NIP-17 + NIP-59):
|
||||
// 1. rumor = unsigned kind:14 chat event
|
||||
// 2. seal = kind:13 signed by sender (rumor encrypted to recipient)
|
||||
// 3. wrap = kind:1059 signed by ephemeral (seal encrypted to recipient)
|
||||
//
|
||||
// Only the wrap goes on the wire; the relay sees an ephemeral pubkey, an
|
||||
// opaque ciphertext, and a #p tag — never the sender or the plaintext.
|
||||
|
||||
import { webcrypto as wc } from "node:crypto";
|
||||
import {
|
||||
finalizeEvent, generateSecretKey, getPublicKey, getEventHash,
|
||||
verifyEvent, nip19, nip44,
|
||||
} from "nostr-tools";
|
||||
|
||||
const enc = new TextEncoder();
|
||||
|
||||
// ---- key derivation --------------------------------------------------------
|
||||
// Same shape password-vault uses: BIP-39 → 64-byte seed → 32-byte purpose root
|
||||
// via HKDF-SHA256 with `info="silentmode/<purpose>"`. Different purpose string,
|
||||
// so passwords/0 and messenger/0 are cryptographically disjoint — one leak
|
||||
// never yields the other.
|
||||
|
||||
/** BIP-39 mnemonic → 64-byte seed (PBKDF2-SHA512, 2048 iters, standard). */
|
||||
export async function bip39ToSeed(mnemonic, passphrase = "") {
|
||||
const km = await wc.subtle.importKey("raw",
|
||||
enc.encode(String(mnemonic).normalize("NFKD").trim()),
|
||||
"PBKDF2", false, ["deriveBits"]);
|
||||
const salt = enc.encode(("mnemonic" + String(passphrase || "")).normalize("NFKD"));
|
||||
return new Uint8Array(await wc.subtle.deriveBits(
|
||||
{ name: "PBKDF2", salt, iterations: 2048, hash: "SHA-512" }, km, 512));
|
||||
}
|
||||
|
||||
/** Seed → 32-byte purpose root via HKDF. Purpose string is public — the
|
||||
* security comes from the seed, not the info parameter. */
|
||||
export async function seedToPurposeRoot(seedBytes, purpose) {
|
||||
const key = await wc.subtle.importKey("raw", seedBytes, "HKDF", false, ["deriveBits"]);
|
||||
const info = enc.encode(`silentmode/${purpose}`);
|
||||
return new Uint8Array(await wc.subtle.deriveBits(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256));
|
||||
}
|
||||
|
||||
/** BIP-39 mnemonic → { sk, pkHex, npub } — the Nostr identity for messaging.
|
||||
* Path is "messenger/0" — reserved for Hermes in
|
||||
* TheseusNavigator/ROADMAP-identity-wallet.md. */
|
||||
export async function nostrKeyFromMnemonic(mnemonic, passphrase = "") {
|
||||
const seed = await bip39ToSeed(mnemonic, passphrase);
|
||||
const sk = await seedToPurposeRoot(seed, "messenger/0");
|
||||
const pkHex = getPublicKey(sk);
|
||||
return { sk, pkHex, npub: nip19.npubEncode(pkHex) };
|
||||
}
|
||||
|
||||
// ---- record parsers --------------------------------------------------------
|
||||
// The BNS resolver is transparent: whatever the name owner published in `r`
|
||||
// lands as entry.records.<key>. Here we validate and normalise the two Hermes
|
||||
// record types.
|
||||
//
|
||||
// np — Nostr pubkey, hex (64) or npub1…
|
||||
// nr — comma/space-separated list of ws(s):// relay URLs
|
||||
|
||||
const HEX_PUBKEY_RE = /^[0-9a-f]{64}$/i;
|
||||
|
||||
export function parseNpRecord(raw) {
|
||||
if (typeof raw !== "string" || !raw.trim()) throw new Error("np: empty or missing");
|
||||
const v = raw.trim();
|
||||
if (HEX_PUBKEY_RE.test(v)) return v.toLowerCase();
|
||||
if (v.startsWith("npub1")) {
|
||||
const d = nip19.decode(v);
|
||||
if (d.type !== "npub" || typeof d.data !== "string" || !HEX_PUBKEY_RE.test(d.data)) {
|
||||
throw new Error("np: npub decoded to unexpected shape");
|
||||
}
|
||||
return d.data.toLowerCase();
|
||||
}
|
||||
throw new Error(`np: not hex(64) and not npub1…: ${v.slice(0, 24)}…`);
|
||||
}
|
||||
|
||||
export function parseNrRecord(raw, { defaults = [] } = {}) {
|
||||
if (typeof raw !== "string" || !raw.trim()) return defaults.slice();
|
||||
const seen = new Set(); const out = [];
|
||||
for (const tok of raw.split(/[,\s]+/)) {
|
||||
const url = tok.trim();
|
||||
if (!/^wss?:\/\//i.test(url)) continue;
|
||||
if (seen.has(url)) continue;
|
||||
seen.add(url); out.push(url);
|
||||
}
|
||||
return out.length ? out : defaults.slice();
|
||||
}
|
||||
|
||||
/** Extract Hermes recipient info from a resolver Entry. */
|
||||
export function parseHermesRecords(entry, { defaultRelays = [] } = {}) {
|
||||
if (!entry || !entry.records) throw new Error("no on-chain entry for name");
|
||||
return {
|
||||
npPk: parseNpRecord(entry.records.np),
|
||||
relays: parseNrRecord(entry.records.nr, { defaults: defaultRelays }),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- NIP-17 wrap / unwrap --------------------------------------------------
|
||||
const KIND_CHAT = 14;
|
||||
const KIND_SEAL = 13;
|
||||
const KIND_GIFT_WRAP = 1059;
|
||||
|
||||
// NIP-59: randomise timestamps up to 2 days in the past so relays can't
|
||||
// correlate wrap-arrival with real send-time.
|
||||
const TWO_DAYS = 2 * 24 * 60 * 60;
|
||||
const jitteredNow = () =>
|
||||
Math.floor(Date.now() / 1000) - Math.floor(Math.random() * TWO_DAYS);
|
||||
|
||||
const convKey = (sk, pkHex) => nip44.v2.utils.getConversationKey(sk, pkHex);
|
||||
const encrypt = (sk, pkHex, plain) => nip44.v2.encrypt(plain, convKey(sk, pkHex));
|
||||
const decrypt = (sk, pkHex, cipher) => nip44.v2.decrypt(cipher, convKey(sk, pkHex));
|
||||
|
||||
/** Wrap a plaintext chat message from senderSk to recipientPkHex. */
|
||||
export function wrapChat({ senderSk, recipientPkHex, text }) {
|
||||
const senderPkHex = getPublicKey(senderSk);
|
||||
|
||||
const rumor = {
|
||||
kind: KIND_CHAT,
|
||||
pubkey: senderPkHex,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["p", recipientPkHex]],
|
||||
content: text,
|
||||
};
|
||||
rumor.id = getEventHash(rumor);
|
||||
|
||||
const seal = finalizeEvent({
|
||||
kind: KIND_SEAL,
|
||||
created_at: jitteredNow(),
|
||||
tags: [],
|
||||
content: encrypt(senderSk, recipientPkHex, JSON.stringify(rumor)),
|
||||
}, senderSk);
|
||||
|
||||
const ephemeralSk = generateSecretKey();
|
||||
return finalizeEvent({
|
||||
kind: KIND_GIFT_WRAP,
|
||||
created_at: jitteredNow(),
|
||||
tags: [["p", recipientPkHex]],
|
||||
content: encrypt(ephemeralSk, recipientPkHex, JSON.stringify(seal)),
|
||||
}, ephemeralSk);
|
||||
}
|
||||
|
||||
/** Unwrap a kind:1059 gift wrap for receiverSk.
|
||||
* Returns { senderPkHex, text, createdAt } or throws on tamper. */
|
||||
export function unwrapChat({ receiverSk, wrap }) {
|
||||
if (wrap.kind !== KIND_GIFT_WRAP) throw new Error(`not a gift wrap (kind ${wrap.kind})`);
|
||||
if (!verifyEvent(wrap)) throw new Error("gift wrap signature invalid");
|
||||
|
||||
const seal = JSON.parse(decrypt(receiverSk, wrap.pubkey, wrap.content));
|
||||
if (seal.kind !== KIND_SEAL) throw new Error(`inner is not a seal (kind ${seal.kind})`);
|
||||
if (!verifyEvent(seal)) throw new Error("seal signature invalid");
|
||||
|
||||
const rumor = JSON.parse(decrypt(receiverSk, seal.pubkey, seal.content));
|
||||
if (rumor.kind !== KIND_CHAT) throw new Error(`inner is not chat (kind ${rumor.kind})`);
|
||||
if (rumor.pubkey !== seal.pubkey) throw new Error("rumor.pubkey does not match seal.pubkey");
|
||||
|
||||
return { senderPkHex: rumor.pubkey, text: rumor.content, createdAt: rumor.created_at };
|
||||
}
|
||||
5
lib/package.json
Normal file
5
lib/package.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"//": "This subfolder holds ES modules loaded from main.js via dynamic import.",
|
||||
"//2": "Theseus's root package.json is commonjs; overriding here lets .js files here parse as ESM without renaming to .mjs.",
|
||||
"type": "module"
|
||||
}
|
||||
265
main.js
265
main.js
|
|
@ -31,6 +31,15 @@ async function loadVaultLib() {
|
|||
if (!vaultLib) vaultLib = await import(`file://${VAULT_MOD.replace(/\\/g, "/")}`);
|
||||
return vaultLib;
|
||||
}
|
||||
// Hermes messaging module — same .mjs-in-resources / .js-in-dev pattern.
|
||||
const HERMES_MOD = app.isPackaged
|
||||
? path.join(RES_DIR, "lib", "hermes.mjs")
|
||||
: path.join(__dirname, "lib", "hermes.js");
|
||||
let hermesLib;
|
||||
async function loadHermesLib() {
|
||||
if (!hermesLib) hermesLib = await import(`file://${HERMES_MOD.replace(/\\/g, "/")}`);
|
||||
return hermesLib;
|
||||
}
|
||||
// Built-in engines. Users can also add their own (settings.customEngines,
|
||||
// each { id, name, url } where the url contains "%s" for the query).
|
||||
// Catalog of built-in engines (users pick which to enable + can add their own).
|
||||
|
|
@ -1804,6 +1813,262 @@ ipcMain.handle("switch-to-bcnr", () => {
|
|||
return loadBns(t, activeId, host, rest || "/", tld);
|
||||
});
|
||||
|
||||
// ---- Hermes messages panel -------------------------------------------------
|
||||
// A separate BrowserWindow (opens on Ctrl+Shift+M anywhere in Theseus). Uses
|
||||
// the wallet mnemonic to derive the Nostr identity in-memory only — the seed
|
||||
// and secret key are never written to disk. The panel process holds one
|
||||
// WebSocket per relay in HERMES_DEFAULT_RELAYS for the receive subscription,
|
||||
// and opens a per-send WebSocket for publishes.
|
||||
const HERMES_DEFAULT_RELAYS = ["wss://nos.lol"];
|
||||
const HERMES_INBOX_LIMIT = 200;
|
||||
|
||||
let hermesWin = null;
|
||||
// State when initialised: { skHex, pkHex, npub, inbox: [], relays: Map<url, { ws, ready }> }
|
||||
// skHex is held instead of the raw Uint8Array so it can be Buffer-restored per operation
|
||||
// (nostr-tools expects Uint8Array; converting on demand keeps the surface easier to reason about).
|
||||
let hermesState = null;
|
||||
|
||||
const hOk = (o = {}) => ({ ok: true, ...o });
|
||||
const hErr = (e) => ({ ok: false, err: String((e && e.message) || e) });
|
||||
|
||||
function hermesEmit(channel, payload) {
|
||||
if (hermesWin && !hermesWin.isDestroyed()) hermesWin.webContents.send(channel, payload);
|
||||
}
|
||||
function hermesRecord(msg) {
|
||||
hermesState.inbox.push(msg);
|
||||
if (hermesState.inbox.length > HERMES_INBOX_LIMIT) {
|
||||
hermesState.inbox.splice(0, hermesState.inbox.length - HERMES_INBOX_LIMIT);
|
||||
}
|
||||
hermesEmit("hermes-message", msg);
|
||||
}
|
||||
// Pushed on every relay connect/disconnect so the pill in the panel reflects
|
||||
// reality without polling. Cheap; sent to the renderer whenever a socket
|
||||
// transitions ready/not-ready.
|
||||
function hermesEmitStatus() {
|
||||
if (!hermesState) return;
|
||||
let connected = 0;
|
||||
for (const s of hermesState.relays.values()) if (s.ready) connected++;
|
||||
hermesEmit("hermes-status-update", {
|
||||
relaysConnected: connected,
|
||||
relaysTotal: hermesState.relays.size,
|
||||
});
|
||||
}
|
||||
|
||||
// Reverse-resolve a pkHex → .bch name by scanning the (cached) BNS index.
|
||||
// Populates senderName in the inbox so incoming DMs render as
|
||||
// "alice.bch · 12ab…9f" instead of a naked hex string. Cache is per-hermesState
|
||||
// (dropped on hermes-close) so a re-init starts clean.
|
||||
async function hermesReverseResolve(pkHex) {
|
||||
if (!hermesState) return null;
|
||||
if (!hermesState._pkToName) hermesState._pkToName = new Map();
|
||||
if (hermesState._pkToName.has(pkHex)) return hermesState._pkToName.get(pkHex);
|
||||
try {
|
||||
const R = await getResolver();
|
||||
const H = await loadHermesLib();
|
||||
const idx = await R.buildIndex({ WebSocket });
|
||||
for (const [name, entry] of idx) {
|
||||
const raw = entry && entry.records && entry.records.np;
|
||||
if (typeof raw !== "string" || !raw.trim()) continue;
|
||||
try {
|
||||
const hex = H.parseNpRecord(raw);
|
||||
if (hex === pkHex) {
|
||||
hermesState._pkToName.set(pkHex, name);
|
||||
return name;
|
||||
}
|
||||
} catch { /* malformed np — skip */ }
|
||||
}
|
||||
} catch { /* chain unreachable — leave unresolved this round */ }
|
||||
hermesState._pkToName.set(pkHex, null); // negative-cache so we don't re-scan every message
|
||||
return null;
|
||||
}
|
||||
|
||||
// One receive subscription per relay. If the socket dies we resurrect it on a
|
||||
// backoff — a locked / logged-out state tears them all down cleanly.
|
||||
async function hermesConnectRelay(url) {
|
||||
const H = await loadHermesLib();
|
||||
const state = { ws: null, ready: false, subId: "hermes-inbox" };
|
||||
const open = () => {
|
||||
if (!hermesState) return; // torn down while reconnecting
|
||||
const ws = new WebSocket(url);
|
||||
state.ws = ws;
|
||||
ws.on("open", () => {
|
||||
state.ready = true;
|
||||
hermesEmitStatus();
|
||||
ws.send(JSON.stringify(["REQ", state.subId, { kinds: [1059], "#p": [hermesState.pkHex] }]));
|
||||
});
|
||||
ws.on("message", async (buf) => {
|
||||
let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
|
||||
if (msg[0] !== "EVENT" || msg[1] !== state.subId) return;
|
||||
try {
|
||||
const sk = Buffer.from(hermesState.skHex, "hex");
|
||||
const opened = H.unwrapChat({ receiverSk: sk, wrap: msg[2] });
|
||||
// Dedupe: a wrap arriving from multiple relays produces the same rumor id
|
||||
// (kind:14 hash), but since we don't expose the rumor id here we dedupe
|
||||
// on (senderPkHex, text, createdAt) which is sufficient for MVP.
|
||||
const key = opened.senderPkHex + "\0" + opened.createdAt + "\0" + opened.text;
|
||||
if (hermesState._seen && hermesState._seen.has(key)) return;
|
||||
hermesState._seen && hermesState._seen.add(key);
|
||||
// Reverse-resolve pk → name off the wrap decrypt path (fire-and-forget
|
||||
// wouldn't work — we need the name in the record we push). Await here;
|
||||
// the cache short-circuits after the first miss per pk.
|
||||
const senderName = await hermesReverseResolve(opened.senderPkHex);
|
||||
hermesRecord({
|
||||
senderPkHex: opened.senderPkHex,
|
||||
senderName,
|
||||
text: opened.text,
|
||||
createdAt: opened.createdAt,
|
||||
});
|
||||
} catch { /* unwrap failure = not for us, or malformed — drop silently */ }
|
||||
});
|
||||
ws.on("close", () => {
|
||||
state.ready = false;
|
||||
hermesEmitStatus();
|
||||
// Attempt reconnect if we're still supposed to be running.
|
||||
if (hermesState && hermesState.relays.get(url) === state) {
|
||||
setTimeout(open, 3000);
|
||||
}
|
||||
});
|
||||
ws.on("error", () => { /* close handler will retry */ });
|
||||
};
|
||||
open();
|
||||
return state;
|
||||
}
|
||||
|
||||
function hermesTeardown() {
|
||||
if (!hermesState) return;
|
||||
for (const state of hermesState.relays.values()) {
|
||||
try { state.ws?.close(1000); } catch {}
|
||||
}
|
||||
hermesState = null;
|
||||
}
|
||||
|
||||
ipcMain.handle("hermes-status", () => {
|
||||
if (!hermesState) return hOk({ ready: false });
|
||||
let connected = 0;
|
||||
for (const s of hermesState.relays.values()) if (s.ready) connected++;
|
||||
return hOk({
|
||||
ready: true,
|
||||
npub: hermesState.npub,
|
||||
pkHex: hermesState.pkHex,
|
||||
relaysConnected: connected,
|
||||
relaysTotal: hermesState.relays.size,
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("hermes-init", async (_e, { mnemonic } = {}) => {
|
||||
if (!mnemonic || typeof mnemonic !== "string" || mnemonic.trim().split(/\s+/).length < 12) {
|
||||
return hErr("enter a 12- or 24-word mnemonic");
|
||||
}
|
||||
try {
|
||||
hermesTeardown();
|
||||
const H = await loadHermesLib();
|
||||
const { sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic.trim());
|
||||
hermesState = {
|
||||
skHex: Buffer.from(sk).toString("hex"),
|
||||
pkHex, npub,
|
||||
inbox: [],
|
||||
relays: new Map(),
|
||||
_seen: new Set(),
|
||||
};
|
||||
for (const url of HERMES_DEFAULT_RELAYS) {
|
||||
hermesState.relays.set(url, await hermesConnectRelay(url));
|
||||
}
|
||||
// Give sockets a beat to open before reporting connected count.
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
let connected = 0;
|
||||
for (const s of hermesState.relays.values()) if (s.ready) connected++;
|
||||
return hOk({ npub, pkHex, relaysConnected: connected, relaysTotal: hermesState.relays.size });
|
||||
} catch (e) { hermesTeardown(); return hErr(e); }
|
||||
});
|
||||
|
||||
ipcMain.handle("hermes-close", () => { hermesTeardown(); return hOk(); });
|
||||
|
||||
ipcMain.handle("hermes-inbox", () => {
|
||||
if (!hermesState) return hErr("not initialised");
|
||||
return hOk({ messages: hermesState.inbox.slice() });
|
||||
});
|
||||
|
||||
// Send: `to` is either a 64-char hex pubkey or a .bch name. Names are resolved
|
||||
// via the portable resolver (getResolver above), the `np` record parsed, then
|
||||
// wrapped + published to each relay listed in `nr` (falling back to defaults).
|
||||
ipcMain.handle("hermes-send", async (_e, { to, text } = {}) => {
|
||||
if (!hermesState) return hErr("not initialised");
|
||||
if (!to || !text) return hErr("to and text required");
|
||||
try {
|
||||
const H = await loadHermesLib();
|
||||
let recipientPkHex; let relays = HERMES_DEFAULT_RELAYS.slice();
|
||||
const trimmed = String(to).trim();
|
||||
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||||
recipientPkHex = trimmed.toLowerCase();
|
||||
} else if (trimmed.startsWith("npub1")) {
|
||||
recipientPkHex = H.parseNpRecord(trimmed);
|
||||
} else {
|
||||
const R = await getResolver();
|
||||
const name = R.normalizeName(trimmed);
|
||||
const entry = await R.resolveName(name, { WebSocket });
|
||||
if (!entry) return hErr(`no on-chain registration for ${name}`);
|
||||
const parsed = H.parseHermesRecords(entry, { defaultRelays: HERMES_DEFAULT_RELAYS });
|
||||
recipientPkHex = parsed.npPk;
|
||||
relays = parsed.relays;
|
||||
}
|
||||
|
||||
const sk = Buffer.from(hermesState.skHex, "hex");
|
||||
const wrap = H.wrapChat({ senderSk: sk, recipientPkHex, text });
|
||||
|
||||
const publishOne = (url) => new Promise((resolve) => {
|
||||
const ws = new WebSocket(url);
|
||||
let settled = false;
|
||||
const done = (r) => { if (settled) return; settled = true; try { ws.close(1000); } catch {} resolve(r); };
|
||||
const t = setTimeout(() => done({ url, ok: false, detail: "timeout" }), 8000);
|
||||
ws.on("open", () => ws.send(JSON.stringify(["EVENT", wrap])));
|
||||
ws.on("message", (buf) => {
|
||||
let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
|
||||
if (msg[0] === "OK" && msg[1] === wrap.id) {
|
||||
clearTimeout(t);
|
||||
done({ url, ok: !!msg[2], detail: msg[3] || "" });
|
||||
}
|
||||
});
|
||||
ws.on("error", (e) => done({ url, ok: false, detail: "ws error: " + e.message }));
|
||||
});
|
||||
|
||||
const results = await Promise.all(relays.map(publishOne));
|
||||
const accepted = results.filter((r) => r.ok).length;
|
||||
return hOk({ recipientPkHex, accepted, total: results.length, results });
|
||||
} catch (e) { return hErr(e); }
|
||||
});
|
||||
|
||||
function openHermesWindow() {
|
||||
if (hermesWin && !hermesWin.isDestroyed()) {
|
||||
hermesWin.focus(); return;
|
||||
}
|
||||
hermesWin = new BrowserWindow({
|
||||
width: 720, height: 620, title: "Messages — Theseus",
|
||||
backgroundColor: "#0b0e14",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "messages-preload.js"),
|
||||
contextIsolation: true, nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
hermesWin.setMenuBarVisibility(false);
|
||||
hermesWin.loadFile("messages.html");
|
||||
hermesWin.on("closed", () => { hermesWin = null; });
|
||||
}
|
||||
|
||||
ipcMain.handle("hermes-open", () => { openHermesWindow(); return { ok: true }; });
|
||||
|
||||
// Ctrl+Shift+M anywhere in Theseus opens (or focuses) the Messages panel.
|
||||
// One app-level hook: no per-window wiring, no per-tab plumbing, matches
|
||||
// however many WebContents Theseus ends up owning.
|
||||
app.on("web-contents-created", (_event, wc) => {
|
||||
wc.on("before-input-event", (e, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
if (input.control && input.shift && (input.key === "M" || input.key === "m")) {
|
||||
openHermesWindow();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// THESEUS_NO_AUTOSTART lets a test harness reuse serveBns/resolveHost without
|
||||
// launching the full UI (see dev/selftest.js). Normal `npm start` is unchanged.
|
||||
if (!process.env.THESEUS_NO_AUTOSTART) {
|
||||
|
|
|
|||
23
messages-preload.js
Normal file
23
messages-preload.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Renderer bridge for the Messages panel.
|
||||
// All hermes-* IPC returns { ok, ... } | { ok: false, err }.
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("hermes", {
|
||||
status: () => ipcRenderer.invoke("hermes-status"),
|
||||
init: (mnemonic) => ipcRenderer.invoke("hermes-init", { mnemonic }),
|
||||
close: () => ipcRenderer.invoke("hermes-close"),
|
||||
inbox: () => ipcRenderer.invoke("hermes-inbox"),
|
||||
send: (to, text) => ipcRenderer.invoke("hermes-send", { to, text }),
|
||||
// Server-push: new decrypted message arrived. Callback gets { senderPkHex, senderName, text, createdAt }.
|
||||
onMessage: (cb) => {
|
||||
const wrapped = (_e, msg) => cb(msg);
|
||||
ipcRenderer.on("hermes-message", wrapped);
|
||||
return () => ipcRenderer.removeListener("hermes-message", wrapped);
|
||||
},
|
||||
// Server-push: relay connection state changed. Callback gets { relaysConnected, relaysTotal }.
|
||||
onStatus: (cb) => {
|
||||
const wrapped = (_e, s) => cb(s);
|
||||
ipcRenderer.on("hermes-status-update", wrapped);
|
||||
return () => ipcRenderer.removeListener("hermes-status-update", wrapped);
|
||||
},
|
||||
});
|
||||
206
messages.html
Normal file
206
messages.html
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Messages — Theseus</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0b0e14; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
|
||||
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
|
||||
--ok:#4fd1a5; --err:#f6768a; --warn:#ffc75f;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
margin:0; background:var(--bg); color:var(--ink);
|
||||
font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
||||
display:flex; flex-direction:column;
|
||||
}
|
||||
header{
|
||||
padding:12px 16px; border-bottom:1px solid var(--line);
|
||||
background:rgba(11,14,20,.9); display:flex; align-items:center; gap:12px;
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
header h1{font-size:14px;font-weight:600;margin:0;letter-spacing:.2px}
|
||||
header .mark{font-size:20px}
|
||||
#ident{
|
||||
color:var(--mut); font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
font-size:12px; margin-left:auto; user-select:text;
|
||||
}
|
||||
#ident b{color:var(--acid); font-weight:500}
|
||||
#status{
|
||||
font-size:11px; padding:2px 8px; border-radius:999px;
|
||||
background:var(--panel); color:var(--mut);
|
||||
}
|
||||
#status.ok{color:var(--ok); background:rgba(79,209,165,.12)}
|
||||
#status.err{color:var(--err); background:rgba(246,118,138,.12)}
|
||||
|
||||
main{display:grid; grid-template-columns:1fr; gap:0; flex:1; min-height:0}
|
||||
#setup, #app{padding:16px; overflow:auto}
|
||||
#setup{display:none; max-width:520px; margin:auto; align-self:center}
|
||||
#app{display:none; grid-template-rows:1fr auto; gap:12px}
|
||||
body.mode-setup #setup{display:block}
|
||||
body.mode-app #app{display:grid}
|
||||
|
||||
#inbox{
|
||||
overflow:auto; background:var(--panel); border:1px solid var(--line);
|
||||
border-radius:10px; padding:8px;
|
||||
}
|
||||
.msg{padding:8px 10px; border-radius:8px; margin-bottom:6px; background:var(--panel2)}
|
||||
.msg .from{font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11px; color:var(--mut); margin-bottom:3px}
|
||||
.msg .from b{color:var(--acid); font-weight:500}
|
||||
.msg .body{white-space:pre-wrap; word-break:break-word}
|
||||
.msg .time{float:right; color:var(--dim); font-size:11px}
|
||||
.empty{color:var(--dim); text-align:center; padding:24px 0}
|
||||
|
||||
#compose{display:grid; grid-template-columns:200px 1fr auto; gap:8px}
|
||||
input, textarea, button{
|
||||
font:inherit; color:var(--ink); background:var(--panel);
|
||||
border:1px solid var(--line); border-radius:8px; padding:10px 12px;
|
||||
}
|
||||
textarea{resize:vertical; min-height:44px; max-height:200px}
|
||||
input:focus, textarea:focus{outline:none; border-color:var(--acid)}
|
||||
button{cursor:pointer; background:var(--acid); color:#0a0d13; font-weight:600; border-color:transparent}
|
||||
button:hover{filter:brightness(1.05)}
|
||||
button:disabled{opacity:.5; cursor:default}
|
||||
|
||||
.lede{color:var(--mut); font-size:13px; margin:0 0 12px}
|
||||
#setup label{display:block; margin-bottom:12px; color:var(--mut); font-size:12px}
|
||||
#setup textarea{width:100%; margin-top:6px; min-height:80px}
|
||||
.row{display:flex; gap:8px; align-items:center; margin-top:8px}
|
||||
#setupErr{color:var(--err); font-size:12px; min-height:1em; margin-top:8px}
|
||||
#sendErr{color:var(--err); font-size:12px; min-height:1em; margin-top:4px}
|
||||
.warn{
|
||||
font-size:12px; color:var(--warn); padding:8px 10px;
|
||||
background:rgba(255,199,95,.08); border:1px solid rgba(255,199,95,.24);
|
||||
border-radius:8px; margin-bottom:12px;
|
||||
}
|
||||
code.inline{
|
||||
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
background:rgba(255,255,255,.05); padding:1px 5px; border-radius:4px; font-size:12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="mode-setup">
|
||||
|
||||
<header>
|
||||
<span class="mark">⛓️✉️</span>
|
||||
<h1>Hermes Messages</h1>
|
||||
<span id="status">connecting…</span>
|
||||
<span id="ident"></span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<section id="setup">
|
||||
<p class="lede">Enter your wallet mnemonic. Hermes derives its Nostr identity at
|
||||
<code class="inline">silentmode/messenger/0</code> — a purpose disjoint from your
|
||||
wallet's transaction key, so a compromised relay never yields tx-signing power.
|
||||
Nothing is stored on disk; close the panel and the identity is dropped.</p>
|
||||
<div class="warn">This is a proof build. Use a wallet you don't hold funds in.</div>
|
||||
<label>
|
||||
Mnemonic (12 or 24 words)
|
||||
<textarea id="mnemonic" placeholder="word word word …" autocomplete="off" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<div class="row">
|
||||
<button id="btnInit">Open messages</button>
|
||||
</div>
|
||||
<div id="setupErr"></div>
|
||||
</section>
|
||||
|
||||
<section id="app">
|
||||
<div id="inbox"><div class="empty">no messages yet — subscribed to inbox…</div></div>
|
||||
<div>
|
||||
<div id="compose">
|
||||
<input id="to" placeholder="to (e.g. alice.bch or 64-char hex pk)" autocomplete="off">
|
||||
<textarea id="text" placeholder="message" rows="1"></textarea>
|
||||
<button id="btnSend">Send</button>
|
||||
</div>
|
||||
<div id="sendErr"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const setMode = (m) => { document.body.className = "mode-" + m; };
|
||||
const setStatus = (text, cls = "") => {
|
||||
const el = $("status");
|
||||
el.textContent = text;
|
||||
el.className = cls;
|
||||
};
|
||||
const shortPk = (pk) => pk ? pk.slice(0, 8) + "…" + pk.slice(-4) : "";
|
||||
// Hermes messaging is a .bch-first product — the default TLD is noise in the
|
||||
// chat list. Strip it for display; other TLDs stay (they disambiguate).
|
||||
const displayName = (name) => name && name.endsWith(".bch") ? name.slice(0, -4) : name;
|
||||
|
||||
async function refreshInbox() {
|
||||
const res = await hermes.inbox();
|
||||
if (!res.ok) return;
|
||||
const box = $("inbox");
|
||||
if (!res.messages.length) {
|
||||
box.innerHTML = '<div class="empty">no messages yet — subscribed to inbox…</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = "";
|
||||
for (const m of res.messages) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "msg";
|
||||
const from = m.senderName ? `<b>${displayName(m.senderName)}</b> · ${shortPk(m.senderPkHex)}` : shortPk(m.senderPkHex);
|
||||
const when = new Date(m.createdAt * 1000).toLocaleTimeString();
|
||||
el.innerHTML = `<div class="from">${from}<span class="time">${when}</span></div>` +
|
||||
`<div class="body"></div>`;
|
||||
el.querySelector(".body").textContent = m.text; // never innerHTML for user content
|
||||
box.appendChild(el);
|
||||
}
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
$("btnInit").addEventListener("click", async () => {
|
||||
const mnemonic = $("mnemonic").value.trim();
|
||||
if (!mnemonic) { $("setupErr").textContent = "enter a mnemonic"; return; }
|
||||
$("btnInit").disabled = true;
|
||||
$("setupErr").textContent = "";
|
||||
const res = await hermes.init(mnemonic);
|
||||
$("btnInit").disabled = false;
|
||||
if (!res.ok) { $("setupErr").textContent = "error: " + res.err; return; }
|
||||
$("ident").innerHTML = "your npub: <b>" + res.npub.slice(0, 12) + "…" + res.npub.slice(-6) + "</b>";
|
||||
setStatus(res.relaysConnected + "/" + res.relaysTotal + " relays", "ok");
|
||||
setMode("app");
|
||||
refreshInbox();
|
||||
});
|
||||
|
||||
$("btnSend").addEventListener("click", async () => {
|
||||
const to = $("to").value.trim();
|
||||
const text = $("text").value.trim();
|
||||
if (!to || !text) { $("sendErr").textContent = "to and message required"; return; }
|
||||
$("btnSend").disabled = true;
|
||||
$("sendErr").textContent = "";
|
||||
const res = await hermes.send(to, text);
|
||||
$("btnSend").disabled = false;
|
||||
if (!res.ok) { $("sendErr").textContent = "error: " + res.err; return; }
|
||||
$("sendErr").textContent = "sent to " + res.accepted + "/" + res.total + " relay(s) — recipient pk " + shortPk(res.recipientPkHex);
|
||||
$("text").value = "";
|
||||
});
|
||||
|
||||
hermes.onMessage(() => refreshInbox());
|
||||
hermes.onStatus((s) => {
|
||||
setStatus(s.relaysConnected + "/" + s.relaysTotal + " relays",
|
||||
s.relaysConnected > 0 ? "ok" : "err");
|
||||
});
|
||||
|
||||
// Query status on load: maybe already initialised from a previous open.
|
||||
hermes.status().then((r) => {
|
||||
if (r && r.ok && r.ready) {
|
||||
$("ident").innerHTML = "your npub: <b>" + r.npub.slice(0, 12) + "…" + r.npub.slice(-6) + "</b>";
|
||||
setStatus(r.relaysConnected + "/" + r.relaysTotal + " relays", "ok");
|
||||
setMode("app");
|
||||
refreshInbox();
|
||||
} else {
|
||||
setStatus("locked", "");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
111
package-lock.json
generated
111
package-lock.json
generated
|
|
@ -1,14 +1,15 @@
|
|||
{
|
||||
"name": "theseus-navigator",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "theseus-navigator",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"dependencies": {
|
||||
"fetch-socks": "^1.3.3",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"socks-proxy-agent": "^10.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
|
|
@ -591,6 +592,45 @@
|
|||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
||||
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/fs": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz",
|
||||
|
|
@ -644,6 +684,42 @@
|
|||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
|
||||
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
||||
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/is": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
||||
|
|
@ -4053,6 +4129,35 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools": {
|
||||
"version": "2.24.2",
|
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.24.2.tgz",
|
||||
"integrity": "sha512-nVf8tvsDPsZvvtT5csRTrMrdrhTId8wl3IgDHt1BWCuL6J+IlmBOW68SCl7SQ6K0PoxenpLPHSKLo4yj49JybA==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "2.1.1",
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0",
|
||||
"@scure/bip32": "2.0.1",
|
||||
"@scure/bip39": "2.0.1",
|
||||
"nostr-wasm": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-wasm": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
|
||||
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/npmlog": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
|
||||
|
|
@ -5021,7 +5126,7 @@
|
|||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"fetch-socks": "^1.3.3",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"socks-proxy-agent": "^10.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
|
|
@ -45,6 +46,9 @@
|
|||
"home-preload.js",
|
||||
"collision.html",
|
||||
"collision-preload.js",
|
||||
"messages.html",
|
||||
"messages-preload.js",
|
||||
"lib/**/*",
|
||||
"package.json",
|
||||
"node_modules/**/*",
|
||||
"!**/*.md",
|
||||
|
|
@ -57,7 +61,8 @@
|
|||
"extraResources": [
|
||||
{ "from": "tor", "to": "tor" },
|
||||
{ "from": "../Argus/src/lib/resolver-web.js", "to": "resolver-web.mjs" },
|
||||
{ "from": "../Argus/src/lib/password-vault.js", "to": "password-vault.mjs" }
|
||||
{ "from": "../Argus/src/lib/password-vault.js", "to": "password-vault.mjs" },
|
||||
{ "from": "lib/hermes.js", "to": "lib/hermes.mjs" }
|
||||
],
|
||||
"win": { "target": ["nsis", "portable"] },
|
||||
"nsis": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue