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:
parent
4133153f32
commit
cee34c47e5
3 changed files with 269 additions and 20 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
140
main.js
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue