diff --git a/lib/hermes.js b/lib/hermes.js index d3aef83..769a07d 100644 --- a/lib/hermes.js +++ b/lib/hermes.js @@ -55,6 +55,20 @@ export async function nostrKeyFromMnemonic(mnemonic, passphrase = "") { 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 diff --git a/main.js b/main.js index feaf6a0..161b319 100644 --- a/main.js +++ b/main.js @@ -1505,17 +1505,23 @@ ipcMain.handle("password-setup", async (_e, { masterPassword, seedSource }) => { if (!masterPassword || String(masterPassword).length < 4) return vaultErr("master password too short"); if (fs.existsSync(vaultFile())) return vaultErr("vault already exists"); const v = await loadVaultLib(); - let purposeRootHex; + let purposeRootHex, messengerRootHex; if (seedSource && seedSource.kind === "mnemonic" && seedSource.mnemonic) { + // Same seed, two purpose roots — one for password derivation, one for + // the Nostr messaging identity. Storing both means Hermes can bind to + // the vault so the user never re-enters the mnemonic. Different HKDF + // info strings keep the two subtrees cryptographically disjoint. const seed = await v.bip39ToSeed(String(seedSource.mnemonic)); - const root = await v.seedToPurposeRoot(seed, "passwords/0"); - purposeRootHex = v.bytesToHex(root); + purposeRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "passwords/0")); + messengerRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "messenger/0")); } else { - // Independent random seed — 32 bytes of purposeRoot directly. + // Independent random seed — 32 bytes of purposeRoot directly. No mnemonic + // means no messenger root; Hermes will fall back to its own mnemonic entry. const root = require("node:crypto").webcrypto.getRandomValues(new Uint8Array(32)); purposeRootHex = v.bytesToHex(root); } - vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex); + vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex, + messengerRootHex ? { messengerRootHex } : {}); emitPwAvailability(); return vaultOk(); } catch (e) { return vaultErr(e?.message || e); } @@ -1955,14 +1961,28 @@ ipcMain.handle("hermes-status", () => { }); }); -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"); - } +// Init modes: +// { mnemonic: "..." } — derive from BIP-39 mnemonic (typed by user) +// { useVault: true } — reuse the password vault's messenger root; no re-entry +// required. Available iff vault is unlocked AND was set +// up from a mnemonic (so messengerRoot was persisted). +ipcMain.handle("hermes-init", async (_e, opts = {}) => { try { hermesTeardown(); const H = await loadHermesLib(); - const { sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic.trim()); + let sk, pkHex, npub; + if (opts.useVault) { + if (!vaultState || !vaultState.messengerRoot) { + return hErr("password vault is locked or was set up without a mnemonic"); + } + ({ sk, pkHex, npub } = H.nostrKeyFromRoot(vaultState.messengerRoot)); + } else { + const mnemonic = opts.mnemonic; + if (!mnemonic || typeof mnemonic !== "string" || mnemonic.trim().split(/\s+/).length < 12) { + return hErr("enter a 12- or 24-word mnemonic"); + } + ({ sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic.trim())); + } hermesState = { skHex: Buffer.from(sk).toString("hex"), pkHex, npub, @@ -1977,10 +1997,17 @@ ipcMain.handle("hermes-init", async (_e, { mnemonic } = {}) => { 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 }); + return hOk({ npub, pkHex, source: opts.useVault ? "vault" : "mnemonic", + relaysConnected: connected, relaysTotal: hermesState.relays.size }); } catch (e) { hermesTeardown(); return hErr(e); } }); +// Cheap query: "is the vault-bound sign-in path available right now?" Panel +// uses this to decide whether to show the 'Use password vault' button. +ipcMain.handle("hermes-can-use-vault", () => hOk({ + available: !!(vaultState && vaultState.messengerRoot), +})); + ipcMain.handle("hermes-close", () => { hermesTeardown(); return hOk(); }); ipcMain.handle("hermes-inbox", () => { diff --git a/messages-preload.js b/messages-preload.js index 07ddfc2..36aa387 100644 --- a/messages-preload.js +++ b/messages-preload.js @@ -3,9 +3,11 @@ 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"), + status: () => ipcRenderer.invoke("hermes-status"), + canUseVault: () => ipcRenderer.invoke("hermes-can-use-vault"), + init: (mnemonic) => ipcRenderer.invoke("hermes-init", { mnemonic }), + initFromVault: () => ipcRenderer.invoke("hermes-init", { useVault: true }), + 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 }. diff --git a/messages.html b/messages.html index 3524b7f..abf0a12 100644 --- a/messages.html +++ b/messages.html @@ -93,11 +93,20 @@
-

Enter your wallet mnemonic. Hermes derives its Nostr identity at +

Hermes derives its Nostr identity at silentmode/messenger/0 — 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.

+ Nothing is stored on disk beyond the vault; close the panel and the identity is dropped.

This is a proof build. Use a wallet you don't hold funds in.
+ + +