Hermes: optional bind to password vault (skip the second mnemonic prompt)

- 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.
This commit is contained in:
Local Dev 2026-08-19 00:38:52 +02:00
parent 7bd4b2b1c3
commit 59d14d0e62
4 changed files with 95 additions and 24 deletions

View file

@ -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.<key>. Here we validate and normalise the two Hermes

49
main.js
View file

@ -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", () => {

View file

@ -4,7 +4,9 @@ const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("hermes", {
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 }),

View file

@ -93,11 +93,20 @@
<main>
<section id="setup">
<p class="lede">Enter your wallet mnemonic. Hermes derives its Nostr identity at
<p class="lede">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>
Nothing is stored on disk beyond the vault; 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>
<div id="vaultBind" style="display:none; margin-bottom:16px">
<div class="row">
<button id="btnVault">Sign in with password vault</button>
<span style="color:var(--mut); font-size:12px">recommended — no mnemonic to re-type</span>
</div>
<div style="text-align:center; color:var(--dim); font-size:12px; margin:14px 0">— or —</div>
</div>
<label>
Mnemonic (12 or 24 words)
<textarea id="mnemonic" placeholder="word word word …" autocomplete="off" spellcheck="false"></textarea>
@ -157,6 +166,17 @@ async function refreshInbox() {
box.scrollTop = box.scrollHeight;
}
function afterInit(res) {
if (!res.ok) { $("setupErr").textContent = "error: " + res.err; return false; }
$("ident").innerHTML = "your npub: <b>" + res.npub.slice(0, 12) + "…" + res.npub.slice(-6) + "</b>"
+ (res.source ? " <span style='color:var(--dim); font-size:11px'>(" + res.source + ")</span>" : "");
setStatus(res.relaysConnected + "/" + res.relaysTotal + " relays",
res.relaysConnected > 0 ? "ok" : "");
setMode("app");
refreshInbox();
return true;
}
$("btnInit").addEventListener("click", async () => {
const mnemonic = $("mnemonic").value.trim();
if (!mnemonic) { $("setupErr").textContent = "enter a mnemonic"; return; }
@ -164,11 +184,15 @@ $("btnInit").addEventListener("click", async () => {
$("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();
afterInit(res);
});
$("btnVault").addEventListener("click", async () => {
$("btnVault").disabled = true;
$("setupErr").textContent = "";
const res = await hermes.initFromVault();
$("btnVault").disabled = false;
afterInit(res);
});
$("btnSend").addEventListener("click", async () => {
@ -191,15 +215,19 @@ hermes.onStatus((s) => {
});
// Query status on load: maybe already initialised from a previous open.
hermes.status().then((r) => {
hermes.status().then(async (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", "");
return;
}
setStatus("locked", "");
// Show the vault sign-in shortcut iff the vault is currently unlocked AND
// was set up from a mnemonic (so it carries the messenger root).
const v = await hermes.canUseVault();
if (v && v.ok && v.available) $("vaultBind").style.display = "block";
});
</script>
</body>