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
44c63eca0f
commit
45a3b595ce
1 changed files with 28 additions and 3 deletions
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue