hermes: name+password signup, Settings panel, seed backup gated by password

Third signin option on the panel: enter a bare name + password, Hermes mints
a 12-word BIP-39 mnemonic locally, derives the Nostr key, password-wraps the
mnemonic for later Settings → Backup, and persists the sealed sk the same
way the other paths do. Save-forever still handles routine signin — the
password is only used to reveal the seed phrase and only from Settings.

- main.js: hermesWrapMnemonic / hermesUnwrapMnemonic via scrypt (N=2^15) +
  aes-256-gcm. Wrapped blob at <userData>/hermes-identity/mnemonic.wrapped.json,
  plaintext JSON — the wrap itself is the protection. On sign-out the blob
  is nuked alongside the sealed sk.
- main.js: init() now takes { create: true, handle, password }. Handle is
  validated (1-32 chars, kebab-friendly) and stored as `<name>.erm` so a
  mainnet free-manifest reservation lands on the same canonical form later.
  Mnemonic-path init optionally takes a password too — "promote this
  seed-phrase session to a password-protected backup" in one step.
- main.js: new _hermesAPI.getBackup({password}) and .hasBackup. status() now
  returns handle + source so the panel can label the restored session.
- panel.html: "Create a new account" button on signin reveals handle +
  password + confirm. ⋯ menu adds Settings… showing account info, display
  mode picker (hermes:alice / alice.erm / alice@hermes.tel — persisted via
  silentmode.storage), and the gated seed-phrase reveal. Reveal lays words
  out 12-across with 1-indexed numbering so a user copying to paper doesn't
  miscount. Backup section hides for vault / mnemonic sessions.

Still off-chain only — handles created this way are local labels until the
mainnet mint of the free manifest lets us register them against the
`hermes.erm` signed-records store (see [[hermes-name-scheme-plan]]).
This commit is contained in:
Local Dev 2026-10-21 17:14:26 +03:00
parent 4133153f32
commit cee34c47e5
3 changed files with 269 additions and 20 deletions

View file

@ -18,9 +18,9 @@
const { ipcMain } = require("electron");
const PANEL_METHODS = [
"status", "canUseVault", "close", "init", "signOut", "inbox", "send",
"markThreadRead", "contactsList", "contactsAdd", "contactsUpdate",
"contactsRemove",
"status", "canUseVault", "close", "init", "signOut", "getBackup", "hasBackup",
"inbox", "send", "markThreadRead",
"contactsList", "contactsAdd", "contactsUpdate", "contactsRemove",
];
module.exports = {

View file

@ -179,10 +179,43 @@
</div>
<!-- overflow menu -->
<div id="menu" style="position:absolute;top:46px;right:10px;z-index:50;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:4px;min-width:150px;display:none;box-shadow:0 6px 24px rgba(0,0,0,.3)">
<div id="menu" style="position:absolute;top:46px;right:10px;z-index:50;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:4px;min-width:170px;display:none;box-shadow:0 6px 24px rgba(0,0,0,.3)">
<button class="menu-item" id="whoBtn" style="display:block;width:100%;text-align:left;background:transparent;border:0;color:var(--mut);font:inherit;font-size:11px;padding:6px 10px;cursor:default">Signed in as —</button>
<div style="height:1px;background:var(--line);margin:2px 0"></div>
<button class="menu-item" id="signOutBtn" style="display:block;width:100%;text-align:left;background:transparent;border:0;color:var(--ink);font:inherit;font-size:13px;padding:7px 10px;cursor:pointer;border-radius:6px">Sign out</button>
<button class="menu-item" id="openSettingsBtn" style="display:block;width:100%;text-align:left;background:transparent;border:0;color:var(--ink);font:inherit;font-size:13px;padding:7px 10px;cursor:pointer;border-radius:6px">Settings…</button>
<button class="menu-item" id="signOutBtn" style="display:block;width:100%;text-align:left;background:transparent;border:0;color:var(--err);font:inherit;font-size:13px;padding:7px 10px;cursor:pointer;border-radius:6px">Sign out</button>
</div>
<!-- Settings modal -->
<div class="modal-bg" id="settingsModal">
<div class="modal" style="max-width:380px">
<h3>Settings</h3>
<div style="font-size:11.5px;color:var(--mut);margin-bottom:4px">Account</div>
<div style="font-size:13px;margin-bottom:4px"><span id="settingsHandle">—</span></div>
<div style="font-size:11px;color:var(--dim);font-family:ui-monospace,monospace;word-break:break-all;margin-bottom:14px" id="settingsNpub">—</div>
<div style="font-size:11.5px;color:var(--mut);margin-bottom:4px">Display handles as</div>
<div style="display:flex;gap:6px;margin-bottom:14px" id="renderModes">
<button class="rmode" data-mode="scheme" style="flex:1;font:inherit;font-size:12px;padding:7px 8px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:6px;cursor:pointer">hermes:alice</button>
<button class="rmode" data-mode="dotted" style="flex:1;font:inherit;font-size:12px;padding:7px 8px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:6px;cursor:pointer">alice.erm</button>
<button class="rmode" data-mode="email" style="flex:1;font:inherit;font-size:12px;padding:7px 8px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:6px;cursor:pointer">alice@hermes.tel</button>
</div>
<div style="font-size:11.5px;color:var(--mut);margin-bottom:4px">Backup</div>
<div id="backupBox">
<div id="backupNone" style="font-size:12px;color:var(--mut);margin-bottom:12px">No seed phrase stored for this account.<br><span style="color:var(--dim);font-size:11px">(created-elsewhere / vault / mnemonic signins don't retain the words)</span></div>
<div id="backupHave" style="display:none">
<input id="backupPassword" type="password" placeholder="your account password" autocomplete="current-password" style="font:inherit;font-size:12.5px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;width:100%;margin-bottom:6px">
<button class="primary" id="revealSeedBtn" style="background:var(--sky);color:var(--sky-ink);border:0;font:inherit;font-weight:600;padding:8px 12px;border-radius:8px;cursor:pointer;width:100%">Reveal seed phrase</button>
<div class="err" id="backupErr" style="color:var(--err);font-size:11.5px;min-height:1em;margin-top:4px"></div>
<div id="backupReveal" style="display:none;font-family:ui-monospace,monospace;font-size:12px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:10px;margin-top:8px;line-height:1.7;word-break:break-word"></div>
</div>
</div>
<div class="row" style="margin-top:16px">
<button class="secondary" id="settingsClose">Close</button>
</div>
</div>
</div>
<!-- ========== SIGNIN SCREEN ========== -->
@ -202,8 +235,16 @@
<p class="lede">End-to-end encrypted DMs. Your identity is your <code>.bch</code> name — same wallet seed as Theseus.</p>
<div class="actions">
<button class="primary" id="useVaultBtn" disabled>Open with Theseus ID</button>
<button class="secondary" id="toggleCreateBtn">Create a new account</button>
<button class="secondary" id="toggleMnemonicBtn">Open an existing account</button>
</div>
<div class="mnemonic" id="createBox">
<input id="createHandle" type="text" placeholder="alice" autocomplete="off" spellcheck="false" style="font-family:inherit;font-size:13px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 10px;width:100%">
<input id="createPassword" type="password" placeholder="password (min 6)" autocomplete="new-password" style="font-family:inherit;font-size:13px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 10px;width:100%">
<input id="createPassword2" type="password" placeholder="confirm password" autocomplete="new-password" style="font-family:inherit;font-size:13px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 10px;width:100%">
<button class="primary" id="useCreateBtn">Create account</button>
<div class="hint">You'll get a <code>hermes:alice</code> handle. We'll generate a seed phrase — back it up in Settings.</div>
</div>
<div class="mnemonic" id="mnemonicBox">
<textarea id="mnemonicInput" placeholder="12 or 24 words, separated by spaces" autocomplete="off" spellcheck="false"></textarea>
<button class="primary" id="useMnemonicBtn">Open</button>
@ -513,13 +554,15 @@ document.addEventListener("click", (e) => {
async function initWithOpts(opts) {
err($("signinErr"), "");
const btn = opts.useVault ? $("useVaultBtn") : $("useMnemonicBtn");
const btn = opts.create ? $("useCreateBtn")
: opts.useVault ? $("useVaultBtn")
: $("useMnemonicBtn");
const prev = btn.textContent;
btn.disabled = true; btn.textContent = "Opening…";
try {
const r = await bridge.invoke("init", opts);
if (!r || !r.ok) throw new Error((r && r.error) || "init failed");
showApp({ pkHex: r.pkHex, npub: r.npub });
if (!r || !r.ok) throw new Error((r && r.err) || (r && r.error) || "init failed");
showApp({ pkHex: r.pkHex, npub: r.npub, handle: r.handle || null });
} catch (e) {
err($("signinErr"), (e && e.message) || String(e));
} finally {
@ -534,12 +577,20 @@ async function initWithOpts(opts) {
return;
}
// Hydrate display preference before the first render.
try {
if (window.silentmode.storage) {
const saved = await window.silentmode.storage.get("renderMode");
if (saved && ["scheme", "dotted", "email"].includes(saved)) state.renderMode = saved;
}
} catch {}
// If a Hermes session is already live (user re-opened the panel), skip signin.
try {
const st = await bridge.invoke("status");
setStatus(st);
if (st && st.ok && st.ready) {
showApp({ pkHex: st.pkHex, npub: st.npub });
showApp({ pkHex: st.pkHex, npub: st.npub, handle: st.handle || null });
}
} catch {}
@ -571,10 +622,25 @@ async function initWithOpts(opts) {
// ---- events ----
$("useVaultBtn").addEventListener("click", () => initWithOpts({ useVault: true }));
$("toggleCreateBtn").addEventListener("click", () => {
$("mnemonicBox").classList.remove("show");
$("createBox").classList.toggle("show");
if ($("createBox").classList.contains("show")) $("createHandle").focus();
});
$("toggleMnemonicBtn").addEventListener("click", () => {
$("createBox").classList.remove("show");
$("mnemonicBox").classList.toggle("show");
if ($("mnemonicBox").classList.contains("show")) $("mnemonicInput").focus();
});
$("useCreateBtn").addEventListener("click", () => {
const handle = $("createHandle").value.trim();
const p1 = $("createPassword").value;
const p2 = $("createPassword2").value;
if (!handle) { err($("signinErr"), "pick a name"); return; }
if (p1.length < 6) { err($("signinErr"), "password must be at least 6 characters"); return; }
if (p1 !== p2) { err($("signinErr"), "passwords don't match"); return; }
initWithOpts({ create: true, handle, password: p1 });
});
$("useMnemonicBtn").addEventListener("click", () => {
const mnemonic = $("mnemonicInput").value;
initWithOpts({ useVault: false, mnemonic });
@ -587,9 +653,74 @@ $("menuBtn").addEventListener("click", (e) => {
});
$("signOutBtn").addEventListener("click", async () => {
closeMenu();
if (!confirm("Sign out of Hermes? Your messages stay on disk; sign in again to see them.")) return;
try { await bridge.invoke("signOut"); } catch {}
showSignin();
});
$("openSettingsBtn").addEventListener("click", async () => {
closeMenu();
await openSettings();
});
$("settingsClose").addEventListener("click", () => $("settingsModal").classList.remove("show"));
$("settingsModal").addEventListener("click", (e) => { if (e.target === $("settingsModal")) $("settingsModal").classList.remove("show"); });
// Display-mode picker. Persisted per-profile via silentmode.storage (the
// addon storage layer). Falls back to in-memory default on first run.
function paintRenderModes() {
for (const b of document.querySelectorAll(".rmode")) {
const active = b.dataset.mode === state.renderMode;
b.style.background = active ? "var(--sky)" : "var(--panel-2)";
b.style.color = active ? "var(--sky-ink)" : "var(--ink)";
b.style.borderColor = active ? "var(--sky)" : "var(--line)";
}
}
for (const b of document.querySelectorAll(".rmode")) {
b.addEventListener("click", async () => {
state.renderMode = b.dataset.mode;
try { if (window.silentmode.storage) await window.silentmode.storage.set("renderMode", state.renderMode); } catch {}
paintRenderModes();
renderContacts();
if (state.selected) renderThread();
});
}
async function openSettings() {
const m = state.me || {};
$("settingsHandle").textContent = m.handle ? renderHandle(m.handle) : "(no handle set)";
$("settingsNpub").textContent = m.npub || m.pkHex || "—";
paintRenderModes();
// Query the host for whether a backup blob exists on disk.
let hasBackup = false;
try {
const r = await bridge.invoke("hasBackup");
hasBackup = !!(r && r.ok && r.hasBackup);
} catch {}
$("backupNone").style.display = hasBackup ? "none" : "block";
$("backupHave").style.display = hasBackup ? "block" : "none";
$("backupPassword").value = "";
$("backupReveal").style.display = "none";
$("backupReveal").textContent = "";
err($("backupErr"), "");
$("settingsModal").classList.add("show");
}
$("revealSeedBtn").addEventListener("click", async () => {
const password = $("backupPassword").value;
if (!password) { err($("backupErr"), "enter your password"); return; }
$("revealSeedBtn").disabled = true;
try {
const r = await bridge.invoke("getBackup", { password });
if (!r || !r.ok) throw new Error((r && r.err) || (r && r.error) || "wrong password");
const words = String(r.mnemonic).trim().split(/\s+/);
$("backupReveal").innerHTML = words.map((w, i) =>
`<span style="display:inline-block;min-width:68px;padding:2px 4px;margin:1px"><span style="color:var(--dim);font-size:10px">${i + 1}</span> ${w}</span>`
).join("");
$("backupReveal").style.display = "block";
err($("backupErr"), "");
} catch (e) {
err($("backupErr"), (e && e.message) || String(e));
} finally { $("revealSeedBtn").disabled = false; }
});
$("search").addEventListener("input", renderContacts);
$("backBtn").addEventListener("click", () => {

140
main.js
View file

@ -9830,6 +9830,84 @@ const hermesIdentityDir = () => path.join(app.getPath("userData"), "hermes-ident
const hermesSealedKeyPath = () => path.join(hermesIdentityDir(), "id.sealed");
const hermesIdentityMetaPath = () => path.join(hermesIdentityDir(), "id.json");
// ---- password-wrapped mnemonic ---------------------------------------------
// Separate from the save-forever sealed-sk path: that one stays live every
// launch via safeStorage (no password prompt, matches the user's "signed in
// forever" ask). THIS blob only gets opened when the user asks to see their
// seed phrase in Settings, and it's wrapped with their account password.
//
// Format on disk (plaintext JSON — the wrap IS the protection):
// { v: 1, kdf: {name: "scrypt", N, r, p, salt}, cipher: "aes-256-gcm",
// nonce, ct, tag }
// scrypt N=2^15 is a balance point: fast enough on a 6-year-old laptop
// (< 400 ms) that a legitimate user doesn't notice, slow enough that
// brute-forcing a 6-digit PIN takes ~weeks on a single machine. Users with
// a stronger password get stronger protection for free.
const nodeCrypto = require("node:crypto");
const HERMES_SCRYPT = { N: 1 << 15, r: 8, p: 1, keyLen: 32 };
function hermesWrapMnemonic(mnemonic, password) {
const salt = nodeCrypto.randomBytes(16);
const key = nodeCrypto.scryptSync(String(password), salt, HERMES_SCRYPT.keyLen,
{ N: HERMES_SCRYPT.N, r: HERMES_SCRYPT.r, p: HERMES_SCRYPT.p });
const nonce = nodeCrypto.randomBytes(12);
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", key, nonce);
const ct = Buffer.concat([cipher.update(String(mnemonic), "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return {
v: 1,
kdf: { name: "scrypt", N: HERMES_SCRYPT.N, r: HERMES_SCRYPT.r, p: HERMES_SCRYPT.p, salt: salt.toString("hex") },
cipher: "aes-256-gcm",
nonce: nonce.toString("hex"),
ct: ct.toString("hex"),
tag: tag.toString("hex"),
};
}
function hermesUnwrapMnemonic(wrap, password) {
if (!wrap || wrap.v !== 1 || !wrap.kdf || wrap.cipher !== "aes-256-gcm") {
throw new Error("unsupported wrap format");
}
const salt = Buffer.from(wrap.kdf.salt, "hex");
const key = nodeCrypto.scryptSync(String(password), salt, HERMES_SCRYPT.keyLen,
{ N: wrap.kdf.N, r: wrap.kdf.r, p: wrap.kdf.p });
const nonce = Buffer.from(wrap.nonce, "hex");
const decipher = nodeCrypto.createDecipheriv("aes-256-gcm", key, nonce);
decipher.setAuthTag(Buffer.from(wrap.tag, "hex"));
const pt = Buffer.concat([decipher.update(Buffer.from(wrap.ct, "hex")), decipher.final()]);
return pt.toString("utf8");
}
const hermesWrappedMnemonicPath = () => path.join(hermesIdentityDir(), "mnemonic.wrapped.json");
function hermesSaveWrappedMnemonic(wrap) {
hermesEnsureDir(hermesIdentityDir());
fs.writeFileSync(hermesWrappedMnemonicPath() + ".tmp", JSON.stringify(wrap, null, 2));
fs.renameSync(hermesWrappedMnemonicPath() + ".tmp", hermesWrappedMnemonicPath());
}
function hermesLoadWrappedMnemonic() {
if (!fs.existsSync(hermesWrappedMnemonicPath())) return null;
try { return JSON.parse(fs.readFileSync(hermesWrappedMnemonicPath(), "utf8")); }
catch { return null; }
}
function hermesForgetWrappedMnemonic() {
try { if (fs.existsSync(hermesWrappedMnemonicPath())) fs.unlinkSync(hermesWrappedMnemonicPath()); } catch {}
}
// ---- handle validation -----------------------------------------------------
// At signup the user picks a bare name (e.g. "alice"). Rules match the parser
// in lib/hermes.js (HANDLE_LOCAL_RE): lowercase a-z, 0-9, hyphen; must start
// and end alphanumeric; 1-32 chars. Stored as "<name>.erm" so a later mainnet
// mint of the free manifest can match without a rewrite — the client-side
// uniqueness check at that point just hits /api/dns/hermes.erm.
const HERMES_HANDLE_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
function hermesCanonicalHandle(bareName) {
const n = String(bareName || "").trim().toLowerCase();
if (!HERMES_HANDLE_RE.test(n)) {
throw new Error("handle must be 1-32 chars: letters, digits, or hyphen (not at the ends)");
}
return `${n}.erm`;
}
function hermesSaveIdentity({ skHex, pkHex, npub, source, handle }) {
if (!safeStorage || !safeStorage.isEncryptionAvailable()) {
console.warn("[hermes] safeStorage unavailable — identity not persisted");
@ -9869,6 +9947,7 @@ function hermesForgetIdentity() {
for (const p of [hermesSealedKeyPath(), hermesIdentityMetaPath()]) {
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch {}
}
hermesForgetWrappedMnemonic();
}
// Called once at startup. If a saved identity exists, bring hermesState up
@ -9901,7 +9980,7 @@ async function hermesAutoRestore() {
hermesState.relays.set(url, await hermesConnectRelay(url));
}
console.log("[hermes] identity restored for", pkHex.slice(0, 10) + "…", "source=" + hermesState.source);
hermesEmit("hermes-restored", { pkHex, npub, source: hermesState.source });
hermesEmit("hermes-restored", { pkHex, npub, source: hermesState.source, handle: hermesState.handle || null });
} catch (e) {
console.error("[hermes] auto-restore failed:", e.message);
}
@ -10279,6 +10358,7 @@ global._hermesAPI = {
let connected = 0;
for (const s of hermesState.relays.values()) if (s.ready) connected++;
return hOk({ ready: true, npub: hermesState.npub, pkHex: hermesState.pkHex,
handle: hermesState.handle || null, source: hermesState.source || null,
relaysConnected: connected, relaysTotal: hermesState.relays.size });
},
canUseVault: () => hOk({ available: !!(vaultState && vaultState.messengerRoot) }),
@ -10290,8 +10370,27 @@ global._hermesAPI = {
try {
hermesTeardown();
const H = await loadHermesLib();
let sk, pkHex, npub, source;
if (opts.useVault) {
let sk, pkHex, npub, source, canonicalHandle = opts.handle || null;
let wrappedMnemonicToSave = null;
if (opts.create) {
// Signup path: user picked a bare name + account password. We mint a
// fresh BIP-39 mnemonic locally, derive the Nostr key from it, and
// password-wrap the mnemonic for Settings → Backup. The password is
// NOT used to encrypt the sealed sk (safeStorage already handles that
// transparently) — the whole point of save-forever is no password at
// every signin.
let handle;
try { handle = hermesCanonicalHandle(opts.handle); }
catch (e) { return hErr(e); }
const password = String(opts.password || "");
if (password.length < 6) return hErr("password must be at least 6 characters");
const bip39 = require("bip39");
const mnemonic = bip39.generateMnemonic(128); // 12 words
({ sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic));
wrappedMnemonicToSave = hermesWrapMnemonic(mnemonic, password);
canonicalHandle = handle;
source = "created";
} else if (opts.useVault) {
if (!vaultState || !vaultState.messengerRoot) {
return hErr("password vault is locked or was set up without a mnemonic");
}
@ -10304,6 +10403,13 @@ global._hermesAPI = {
}
({ sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic.trim()));
source = "mnemonic";
// Mnemonic path: user just typed it. Password-wrapping here is
// optional — only do it if they also provided a password (lets a
// seed-phrase sign-in optionally promote to a password-protected
// backup slot in one step).
if (opts.password && String(opts.password).length >= 6) {
wrappedMnemonicToSave = hermesWrapMnemonic(mnemonic.trim(), opts.password);
}
}
const skHex = Buffer.from(sk).toString("hex");
const persisted = hermesLoadInboxFromDisk(pkHex);
@ -10313,7 +10419,7 @@ global._hermesAPI = {
skHex, pkHex, npub, inbox: persisted,
readState: hermesLoadReadState(pkHex),
relays: new Map(), _seen: seen,
source, handle: opts.handle || null,
source, handle: canonicalHandle,
};
for (const url of HERMES_DEFAULT_RELAYS) {
hermesState.relays.set(url, await hermesConnectRelay(url));
@ -10322,19 +10428,31 @@ global._hermesAPI = {
let connected = 0;
for (const s of hermesState.relays.values()) if (s.ready) connected++;
// Persist by default. Caller passes `remember: false` for a one-shot
// signin that disappears when hermesTeardown runs. The vault path only
// persists if the user said so — vault-wrapped identities are
// re-derivable from the already-unlocked vault, so saving the sealed
// skHex a second time is write-amplification, not security.
// signin that disappears when hermesTeardown runs.
const remember = opts.remember !== false;
if (remember) {
hermesSaveIdentity({ skHex, pkHex, npub, source, handle: opts.handle || null });
hermesSaveIdentity({ skHex, pkHex, npub, source, handle: canonicalHandle });
if (wrappedMnemonicToSave) hermesSaveWrappedMnemonic(wrappedMnemonicToSave);
}
return hOk({ npub, pkHex, source,
return hOk({ npub, pkHex, source, handle: canonicalHandle,
relaysConnected: connected, relaysTotal: hermesState.relays.size,
persisted: remember });
persisted: remember, hasBackup: !!wrappedMnemonicToSave });
} catch (e) { hermesTeardown(); return hErr(e); }
},
// Settings → Backup. Returns the 12-word mnemonic if the user's password
// unwraps the stored blob. Rate-limiting is caller-side (the panel disables
// the field after 5 wrong tries for 15 min, matching the Aegis PIN gate
// model in [[aegis-pin-host-checked]]).
getBackup: ({ password } = {}) => {
const wrap = hermesLoadWrappedMnemonic();
if (!wrap) return hErr("no backup available — this account was signed in with a seed phrase or the vault, not created here");
if (!password || typeof password !== "string") return hErr("password required");
try {
const mnemonic = hermesUnwrapMnemonic(wrap, password);
return hOk({ mnemonic });
} catch { return hErr("wrong password"); }
},
hasBackup: () => hOk({ hasBackup: !!hermesLoadWrappedMnemonic() }),
signOut: () => {
hermesForgetIdentity();
hermesTeardown();