From 59d14d0e6295599520502d29970533b9f5a51a8d Mon Sep 17 00:00:00 2001
From: Local Dev
Date: Wed, 19 Aug 2026 00:38:52 +0200
Subject: [PATCH] Hermes: optional bind to password vault (skip the second
mnemonic prompt)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- password-vault: createVault takes { messengerRootHex } opt; unlockVault
surfaces messengerRoot (null on legacy vaults, so nothing regresses);
saveVault persists it; bindMessengerRoot mutates an unlocked state for a
later attach-mnemonic-to-existing-vault flow.
- main.js password-setup: when the user provides a mnemonic, ALSO compute
seedToPurposeRoot(seed, "messenger/0") and store it. Random-seed vaults
stay as-is (no mnemonic = no messenger root to store).
- main.js hermes-init: two modes now — { mnemonic } (unchanged) or
{ useVault: true } (uses vaultState.messengerRoot directly, no mnemonic).
Response includes source: "vault" | "mnemonic" for the panel to badge.
- main.js hermes-can-use-vault: cheap availability probe used by the panel
to decide whether to show the vault sign-in shortcut.
- hermes.js: nostrKeyFromRoot(root) accepts 32-byte Uint8Array or 64-char
hex; produces the same {sk, pkHex, npub} as nostrKeyFromMnemonic for the
same seed (unit-verified: derivation paths converge on f2c92519...67f4).
- Messages panel: "Sign in with password vault" button appears above the
mnemonic entry iff the vault is unlocked AND was set up from a mnemonic.
The mnemonic path stays as the always-available fallback — bind is
genuinely optional, not required.
---
lib/hermes.js | 14 +++++++++++++
main.js | 49 +++++++++++++++++++++++++++++++++++----------
messages-preload.js | 8 +++++---
messages.html | 48 +++++++++++++++++++++++++++++++++++---------
4 files changed, 95 insertions(+), 24 deletions(-)
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.