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:
parent
7bd4b2b1c3
commit
59d14d0e62
4 changed files with 95 additions and 24 deletions
|
|
@ -55,6 +55,20 @@ export async function nostrKeyFromMnemonic(mnemonic, passphrase = "") {
|
||||||
return { sk, pkHex, npub: nip19.npubEncode(pkHex) };
|
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 --------------------------------------------------------
|
// ---- record parsers --------------------------------------------------------
|
||||||
// The BNS resolver is transparent: whatever the name owner published in `r`
|
// 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
|
// lands as entry.records.<key>. Here we validate and normalise the two Hermes
|
||||||
|
|
|
||||||
49
main.js
49
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 (!masterPassword || String(masterPassword).length < 4) return vaultErr("master password too short");
|
||||||
if (fs.existsSync(vaultFile())) return vaultErr("vault already exists");
|
if (fs.existsSync(vaultFile())) return vaultErr("vault already exists");
|
||||||
const v = await loadVaultLib();
|
const v = await loadVaultLib();
|
||||||
let purposeRootHex;
|
let purposeRootHex, messengerRootHex;
|
||||||
if (seedSource && seedSource.kind === "mnemonic" && seedSource.mnemonic) {
|
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 seed = await v.bip39ToSeed(String(seedSource.mnemonic));
|
||||||
const root = await v.seedToPurposeRoot(seed, "passwords/0");
|
purposeRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "passwords/0"));
|
||||||
purposeRootHex = v.bytesToHex(root);
|
messengerRootHex = v.bytesToHex(await v.seedToPurposeRoot(seed, "messenger/0"));
|
||||||
} else {
|
} 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));
|
const root = require("node:crypto").webcrypto.getRandomValues(new Uint8Array(32));
|
||||||
purposeRootHex = v.bytesToHex(root);
|
purposeRootHex = v.bytesToHex(root);
|
||||||
}
|
}
|
||||||
vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex);
|
vaultState = await v.createVault(vaultFile(), masterPassword, purposeRootHex,
|
||||||
|
messengerRootHex ? { messengerRootHex } : {});
|
||||||
emitPwAvailability();
|
emitPwAvailability();
|
||||||
return vaultOk();
|
return vaultOk();
|
||||||
} catch (e) { return vaultErr(e?.message || e); }
|
} catch (e) { return vaultErr(e?.message || e); }
|
||||||
|
|
@ -1955,14 +1961,28 @@ ipcMain.handle("hermes-status", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("hermes-init", async (_e, { mnemonic } = {}) => {
|
// Init modes:
|
||||||
if (!mnemonic || typeof mnemonic !== "string" || mnemonic.trim().split(/\s+/).length < 12) {
|
// { mnemonic: "..." } — derive from BIP-39 mnemonic (typed by user)
|
||||||
return hErr("enter a 12- or 24-word mnemonic");
|
// { 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 {
|
try {
|
||||||
hermesTeardown();
|
hermesTeardown();
|
||||||
const H = await loadHermesLib();
|
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 = {
|
hermesState = {
|
||||||
skHex: Buffer.from(sk).toString("hex"),
|
skHex: Buffer.from(sk).toString("hex"),
|
||||||
pkHex, npub,
|
pkHex, npub,
|
||||||
|
|
@ -1977,10 +1997,17 @@ ipcMain.handle("hermes-init", async (_e, { mnemonic } = {}) => {
|
||||||
await new Promise((r) => setTimeout(r, 400));
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
let connected = 0;
|
let connected = 0;
|
||||||
for (const s of hermesState.relays.values()) if (s.ready) connected++;
|
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); }
|
} 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-close", () => { hermesTeardown(); return hOk(); });
|
||||||
|
|
||||||
ipcMain.handle("hermes-inbox", () => {
|
ipcMain.handle("hermes-inbox", () => {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("hermes", {
|
contextBridge.exposeInMainWorld("hermes", {
|
||||||
status: () => ipcRenderer.invoke("hermes-status"),
|
status: () => ipcRenderer.invoke("hermes-status"),
|
||||||
|
canUseVault: () => ipcRenderer.invoke("hermes-can-use-vault"),
|
||||||
init: (mnemonic) => ipcRenderer.invoke("hermes-init", { mnemonic }),
|
init: (mnemonic) => ipcRenderer.invoke("hermes-init", { mnemonic }),
|
||||||
|
initFromVault: () => ipcRenderer.invoke("hermes-init", { useVault: true }),
|
||||||
close: () => ipcRenderer.invoke("hermes-close"),
|
close: () => ipcRenderer.invoke("hermes-close"),
|
||||||
inbox: () => ipcRenderer.invoke("hermes-inbox"),
|
inbox: () => ipcRenderer.invoke("hermes-inbox"),
|
||||||
send: (to, text) => ipcRenderer.invoke("hermes-send", { to, text }),
|
send: (to, text) => ipcRenderer.invoke("hermes-send", { to, text }),
|
||||||
|
|
|
||||||
|
|
@ -93,11 +93,20 @@
|
||||||
<main>
|
<main>
|
||||||
|
|
||||||
<section id="setup">
|
<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
|
<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.
|
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 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>
|
<label>
|
||||||
Mnemonic (12 or 24 words)
|
Mnemonic (12 or 24 words)
|
||||||
<textarea id="mnemonic" placeholder="word word word …" autocomplete="off" spellcheck="false"></textarea>
|
<textarea id="mnemonic" placeholder="word word word …" autocomplete="off" spellcheck="false"></textarea>
|
||||||
|
|
@ -157,6 +166,17 @@ async function refreshInbox() {
|
||||||
box.scrollTop = box.scrollHeight;
|
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 () => {
|
$("btnInit").addEventListener("click", async () => {
|
||||||
const mnemonic = $("mnemonic").value.trim();
|
const mnemonic = $("mnemonic").value.trim();
|
||||||
if (!mnemonic) { $("setupErr").textContent = "enter a mnemonic"; return; }
|
if (!mnemonic) { $("setupErr").textContent = "enter a mnemonic"; return; }
|
||||||
|
|
@ -164,11 +184,15 @@ $("btnInit").addEventListener("click", async () => {
|
||||||
$("setupErr").textContent = "";
|
$("setupErr").textContent = "";
|
||||||
const res = await hermes.init(mnemonic);
|
const res = await hermes.init(mnemonic);
|
||||||
$("btnInit").disabled = false;
|
$("btnInit").disabled = false;
|
||||||
if (!res.ok) { $("setupErr").textContent = "error: " + res.err; return; }
|
afterInit(res);
|
||||||
$("ident").innerHTML = "your npub: <b>" + res.npub.slice(0, 12) + "…" + res.npub.slice(-6) + "</b>";
|
});
|
||||||
setStatus(res.relaysConnected + "/" + res.relaysTotal + " relays", "ok");
|
|
||||||
setMode("app");
|
$("btnVault").addEventListener("click", async () => {
|
||||||
refreshInbox();
|
$("btnVault").disabled = true;
|
||||||
|
$("setupErr").textContent = "";
|
||||||
|
const res = await hermes.initFromVault();
|
||||||
|
$("btnVault").disabled = false;
|
||||||
|
afterInit(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btnSend").addEventListener("click", async () => {
|
$("btnSend").addEventListener("click", async () => {
|
||||||
|
|
@ -191,15 +215,19 @@ hermes.onStatus((s) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Query status on load: maybe already initialised from a previous open.
|
// 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) {
|
if (r && r.ok && r.ready) {
|
||||||
$("ident").innerHTML = "your npub: <b>" + r.npub.slice(0, 12) + "…" + r.npub.slice(-6) + "</b>";
|
$("ident").innerHTML = "your npub: <b>" + r.npub.slice(0, 12) + "…" + r.npub.slice(-6) + "</b>";
|
||||||
setStatus(r.relaysConnected + "/" + r.relaysTotal + " relays", "ok");
|
setStatus(r.relaysConnected + "/" + r.relaysTotal + " relays", "ok");
|
||||||
setMode("app");
|
setMode("app");
|
||||||
refreshInbox();
|
refreshInbox();
|
||||||
} else {
|
return;
|
||||||
setStatus("locked", "");
|
|
||||||
}
|
}
|
||||||
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue