// 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"; // --- 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 -------------------------------------------------------- // Same shape password-vault uses: BIP-39 → 64-byte seed → 32-byte purpose root // via HKDF-SHA256 with `info="silentmode/"`. 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) }; } /** Same identity but from an already-derived messenger/0 root (32 bytes) — * used when the password vault already carries the root and re-entering the * mnemonic would be redundant. Input is Uint8Array(32) or 64-char hex. */ export function nostrKeyFromRoot(root) { const sk = typeof root === "string" ? Uint8Array.from(Buffer.from(root, "hex")) : root; if (!(sk instanceof Uint8Array) || sk.length !== 32) { throw new Error("nostrKeyFromRoot: expected 32-byte Uint8Array or 64-char hex"); } 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.. 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; // ---- handle forms ---------------------------------------------------------- // Hermes accepts three equivalent handle forms, all resolving to the same // BNS registration under the hood: // // hermes:alice — URI scheme, hides the TLD (default display) // alice.erm — dotted BNS form // alice@hermes.tel — email form; `hermes.tel` is a BNS name whose // signed-records manifest lists sub-identities // // `.erm` and `hermes.tel` are the planned on-chain names; both mint on // mainnet, pending user action. Until then, parseHandle still accepts all // three forms — resolution against the real chain just returns "not found" // for unminted TLDs/parents. Existing `.bch` names keep working untouched. export const HERMES_SCHEME = "hermes"; export const HERMES_TLD = "erm"; export const HERMES_EMAIL_PARENT = "hermes.tel"; const HANDLE_LOCAL_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/; const DOTTED_NAME_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/; /** * Normalize any accepted handle form to a canonical lookup descriptor: * { kind: "hex", value: "<64 hex>" } * { kind: "npub", value: "npub1…" } * { kind: "name", value: "alice.erm" } — BNS name lookup * { kind: "sub", value: { local: "alice", parent: "hermes.tel" } } * — signed-records * sub-identity lookup */ export function parseHandle(input) { const s = String(input || "").trim(); if (!s) throw new Error("empty handle"); if (/^[0-9a-f]{64}$/i.test(s)) return { kind: "hex", value: s.toLowerCase() }; if (s.toLowerCase().startsWith("npub1")) return { kind: "npub", value: s }; const lower = s.toLowerCase(); // hermes:alice — expand to the default TLD. `.erm` is the planned name, // but any TLD switch is a one-line change to HERMES_TLD above. const schemePrefix = HERMES_SCHEME + ":"; if (lower.startsWith(schemePrefix)) { const local = lower.slice(schemePrefix.length).trim(); if (!HANDLE_LOCAL_RE.test(local)) throw new Error(`bad hermes: handle: ${s.slice(0, 48)}`); return { kind: "name", value: `${local}.${HERMES_TLD}` }; } // alice@parent.tld — email form, treated as a sub-identity under parent. if (lower.includes("@")) { const at = lower.indexOf("@"); const local = lower.slice(0, at); const parent = lower.slice(at + 1); if (!HANDLE_LOCAL_RE.test(local) || !DOTTED_NAME_RE.test(parent)) { throw new Error(`bad email handle: ${s.slice(0, 48)}`); } return { kind: "sub", value: { local, parent } }; } // alice.erm, alice.bch, alice.tel — dotted BNS name. Must have at least // one dot to disambiguate from "bare" sub-identity requests (which the // client enriches with the default parent before calling in). if (DOTTED_NAME_RE.test(lower)) { return { kind: "name", value: lower }; } throw new Error(`unrecognised handle: ${s.slice(0, 48)}`); } /** * Render a canonical descriptor (or a BNS name) in the user's preferred form. * Modes: * "scheme" (default) → hermes:alice (only when the name is under HERMES_TLD) * "dotted" → alice.erm * "email" → alice@hermes.tel (only for HERMES_TLD names) * Non-hermes names (alice.bch, bob.sc) always render dotted — the scheme * and email forms are reserved for the Hermes TLD so a .bch name can't * accidentally look like a sub-identity. */ export function renderHandle(name, mode = "scheme") { const n = String(name || "").toLowerCase(); if (!n || !n.includes(".")) return n; const suffix = "." + HERMES_TLD; if (!n.endsWith(suffix)) return n; const local = n.slice(0, -suffix.length); if (mode === "dotted") return n; if (mode === "email") return `${local}@${HERMES_EMAIL_PARENT}`; return `${HERMES_SCHEME}:${local}`; } 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 }), }; } // ---- Kind 0 profile (display metadata) ------------------------------------- // NIP-01 defines kind:0 as "set_metadata" — a user-authored event whose // content is a JSON blob with human-readable fields. Standard fields we // touch: `name`, `display_name`, `picture`, `about`. Nostr clients fetch // kind:0 by author pubkey, newest wins; the sender's `name` renders in // chat lists next to messages. // // Hermes keeps the unique handle (BNS registration) and the display metadata // (kind:0) orthogonal — the handle is for addressing, the metadata is for // how you WANT to appear. Users can leave kind:0 empty and still receive // messages; conversely a kind:0 with just a nickname is enough to make the // chat list look like a normal chat list without going on-chain. export function buildProfileEvent({ sk, displayName, picture, about, extra } = {}) { if (!sk) throw new Error("sk required"); const content = { ...(extra || {}) }; if (displayName) { content.display_name = String(displayName); content.name = String(displayName); } if (picture) content.picture = String(picture); if (about) content.about = String(about); return finalizeEvent({ kind: 0, content: JSON.stringify(content), tags: [], created_at: Math.floor(Date.now() / 1000), }, sk); } export function parseProfileContent(content) { if (!content || typeof content !== "string") return null; try { const o = JSON.parse(content); if (!o || typeof o !== "object") return null; return { displayName: typeof o.display_name === "string" ? o.display_name : typeof o.name === "string" ? o.name : null, picture: typeof o.picture === "string" ? o.picture : null, about: typeof o.about === "string" ? o.about : null, }; } catch { return null; } } // ---- phone discovery (opt-in) ---------------------------------------------- // A user who opts in publishes one Nostr event per phone number they want // to be reachable at. The number itself never leaves the device — only a // hash is sent. Someone who already knows the number can hash it and query // the relay to find the Hermes identity behind it; someone who doesn't has // to brute-force a 256-bit space constrained by phone-number plausibility // (same honest limit as Signal / WhatsApp without PSI). // // Event shape: kind:30078 parameterised replaceable, d-tag = // `hermes.phone/`, pubkey = the user's Hermes npub. Content is // empty JSON — the association IS the (d-tag, pubkey) pair; no payload // needed. Deletion is a plain kind:5 referencing the event id. // // Normalisation: strip everything except digits and a leading +. If the // input doesn't start with + we treat it as E.164-missing-prefix and // refuse — the UI prompts for country code. One format in, one format // hashed; otherwise users with the same number but different formatting // wouldn't match. export function normalisePhone(input) { const s = String(input || "").trim(); if (!s) throw new Error("empty phone"); // Keep + and digits only. Users often paste "+1 (555) 123-4567" — all // separators drop silently; a leading "00" is treated as "+" for Europe // dialing habits. const cleaned = s.replace(/[^\d+]/g, ""); if (!cleaned) throw new Error("empty phone"); let normalised; if (cleaned.startsWith("+")) normalised = "+" + cleaned.slice(1).replace(/^0+/, ""); else if (cleaned.startsWith("00")) normalised = "+" + cleaned.slice(2).replace(/^0+/, ""); else throw new Error("include a + country code, e.g. +15551234567"); if (!/^\+[1-9]\d{6,14}$/.test(normalised)) { throw new Error("phone must be 7-15 digits after the country code"); } return normalised; } /** SHA-256(E.164) → base64url, truncated to 32 chars for d-tag compactness. * Collision space ~2^192 — still infeasible to collide a real-world phone, * while keeping the d-tag short in relay-filter payloads. */ export async function hashPhone(input) { const e164 = normalisePhone(input); const buf = await wc.subtle.digest("SHA-256", enc.encode(e164)); const b64 = Buffer.from(new Uint8Array(buf)).toString("base64") .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return b64.slice(0, 32); } export const PHONE_DTAG_PREFIX = "hermes.phone/"; export async function buildPhoneClaimEvent({ sk, phone } = {}) { if (!sk) throw new Error("sk required"); const hash = await hashPhone(phone); return finalizeEvent({ kind: 30078, content: "{}", tags: [["d", PHONE_DTAG_PREFIX + hash]], created_at: Math.floor(Date.now() / 1000), }, sk); } /** 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({ kind: 5, content: "retracting hermes phone claim", tags: eventIds.map((id) => ["e", id]), created_at: Math.floor(Date.now() / 1000), }, sk); } // ---- 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 }; }