From 45a3b595cecee6b31dbcfb93d0098ba537d85710 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. --- src/lib/password-vault.js | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/lib/password-vault.js b/src/lib/password-vault.js index bc48998..54b050f 100644 --- a/src/lib/password-vault.js +++ b/src/lib/password-vault.js @@ -123,15 +123,25 @@ function writeVaultFile(path, { salt, iv, ct, iters }) { renameSync(path + ".tmp", path); } -export async function createVault(path, masterPw, purposeRootHex) { +// Extra plaintext fields are additive: legacy vaults omit `messengerRoot` and +// keep working; new vaults created from a mnemonic pre-compute it so Hermes +// can pick it up without re-entering the mnemonic. Never persist the seed +// itself — only the per-purpose HKDF root, so a leak of one purpose (e.g. a +// stolen password derivation) never yields another (e.g. the messaging key). +export async function createVault(path, masterPw, purposeRootHex, opts = {}) { const salt = wc.getRandomValues(new Uint8Array(16)); const iv = wc.getRandomValues(new Uint8Array(12)); const key = await deriveVaultKey(masterPw, salt, PBKDF2_ITERS); const plaintext = { purposeRoot: purposeRootHex, entries: [] }; + if (opts.messengerRootHex) plaintext.messengerRoot = opts.messengerRootHex; const ct = new Uint8Array(await wc.subtle.encrypt( { name: "AES-GCM", iv }, key, enc.encode(JSON.stringify(plaintext)))); writeVaultFile(path, { salt, iv, ct, iters: PBKDF2_ITERS }); - return { key, purposeRoot: purposeRootHex, entries: [], _salt: salt, _iters: PBKDF2_ITERS }; + return { + key, purposeRoot: purposeRootHex, + messengerRoot: opts.messengerRootHex || null, + entries: [], _salt: salt, _iters: PBKDF2_ITERS, + }; } export async function unlockVault(path, masterPw) { @@ -145,17 +155,32 @@ export async function unlockVault(path, masterPw) { try { pt = new Uint8Array(await wc.subtle.decrypt({ name: "AES-GCM", iv }, key, ct)); } catch { throw new Error("bad master password"); } const plaintext = JSON.parse(dec.decode(pt)); - return { key, purposeRoot: plaintext.purposeRoot, entries: plaintext.entries || [], _salt: salt, _iters: raw.kdf.iters }; + return { + key, purposeRoot: plaintext.purposeRoot, + messengerRoot: plaintext.messengerRoot || null, + entries: plaintext.entries || [], + _salt: salt, _iters: raw.kdf.iters, + }; } export async function saveVault(path, state) { const iv = wc.getRandomValues(new Uint8Array(12)); const plaintext = { purposeRoot: state.purposeRoot, entries: state.entries }; + if (state.messengerRoot) plaintext.messengerRoot = state.messengerRoot; const ct = new Uint8Array(await wc.subtle.encrypt( { name: "AES-GCM", iv }, state.key, enc.encode(JSON.stringify(plaintext)))); writeVaultFile(path, { salt: state._salt, iv, ct, iters: state._iters }); } +// Retroactively bind an existing (unlocked) vault to a mnemonic by adding the +// messenger root. Useful for users who set up the vault before Hermes existed. +// Callers must have vetted the mnemonic (typically by re-deriving the vault's +// existing purposeRoot from it and comparing) so we don't silently accept a +// mismatched seed. Returns the new state; caller saves. +export async function bindMessengerRoot(state, messengerRootHex) { + return { ...state, messengerRoot: String(messengerRootHex) }; +} + // -- entry mutations (operate on an unlocked state object) -------------------- export function newEntry({ domain, username = "", literal, generated } = {}) { if (!domain) throw new Error("entry: domain required");