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:
Local Dev 2026-08-18 20:14:43 +02:00
parent bf996bdb0c
commit 7bd4b2b1c3
7 changed files with 775 additions and 4 deletions

162
lib/hermes.js Normal file
View 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
View 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
View file

@ -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
View 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
View 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
View file

@ -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",

View file

@ -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": {