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 44c63eca0f
commit 45a3b595ce

View file

@ -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");