hermes: cloud-wrapped backup (opt-in B) + mobile site polish

Users can now sign in with name + password on a device that has never seen the
account by opting into a cloud backup at account creation. The seed phrase is
locally re-wrapped with scrypt + AES-GCM keyed by the password and published
as a NIP-78 kind:30078 event under d-tag `hermes.backup.v1/<sha256(name)>`.
Relays only ever see opaque ciphertext; the password is the only decryption
key, so backup strength is password strength.

Signin tries the local wrap first (fast, no network) and falls back to the
cloud copy if the device hasn't been used before. Settings → Backup now has
"Back up to cloud" / "Re-upload" / "Remove cloud backup" controls that reuse
the Reveal-seed password field as the key.

Site: added @media breakpoints to shared.css so the header collapses cleanly
on narrow screens (icon-only brand under 380px, hidden "Sign in" label under
640px), the hero mark drops to 84px, h1 scales down, and buttons shrink to
touch sizes. Also fixed a stale reference to the removed `mnemonicBox` id in
the Create-account toggle that was throwing a TypeError on mobile.
This commit is contained in:
Local Dev 2026-10-22 03:46:45 +03:00
parent 6b4654231b
commit bbe5d3d298
5 changed files with 213 additions and 7 deletions

File diff suppressed because one or more lines are too long

View file

@ -19,6 +19,7 @@ const { ipcMain } = require("electron");
const PANEL_METHODS = [
"status", "canUseVault", "close", "init", "signOut", "getBackup", "hasBackup",
"publishCloudBackup", "fetchCloudBackup", "hasCloudBackup", "removeCloudBackup",
"getProfile", "updateProfile",
"getPhone", "setPhone", "clearPhone", "findByPhones",
"inbox", "send", "markThreadRead",

View file

@ -234,6 +234,20 @@
<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>
<!-- Cloud backup controls — opt-in B. Visible only when a local wrap
exists (otherwise there's nothing to upload). The password field
above doubles as the key for publishing/removing the cloud copy. -->
<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--line)">
<div style="font-size:11.5px;color:var(--mut);margin-bottom:4px">Cloud backup</div>
<div id="cloudBackupStatus" style="font-size:11.5px;color:var(--mut);margin-bottom:6px"></div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button id="cloudBackupBtn" style="flex:1;min-width:120px;font:inherit;font-size:12px;padding:7px 10px;background:var(--sky);color:var(--sky-ink);border:0;border-radius:6px;cursor:pointer;font-weight:600">Back up to cloud</button>
<button id="cloudRemoveBtn" style="flex:1;min-width:120px;font:inherit;font-size:12px;padding:7px 10px;background:transparent;color:var(--ink);border:1px solid var(--line);border-radius:6px;cursor:pointer;display:none">Remove cloud backup</button>
</div>
<div id="cloudBackupErr" style="color:var(--err);font-size:11.5px;min-height:1em;margin-top:4px"></div>
<div style="font-size:11px;color:var(--dim);margin-top:4px">Lets you sign in with name + password on another device. Your password is the key; relays only see opaque ciphertext.</div>
</div>
</div>
</div>
@ -258,6 +272,10 @@
<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%">
<label style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--mut);text-align:left;cursor:pointer">
<input id="createCloud" type="checkbox" style="margin-top:3px;accent-color:var(--sky)">
<span>Back up to the cloud so I can sign in on other devices with this name + password. (You can turn this off in Settings. Your password is the only key; relays never see the seed.)</span>
</label>
<button class="primary" id="useCreateBtn">Create account</button>
<div class="hint">You'll get a handle like <code>alice.erm</code>. We'll generate a seed phrase — back it up in Settings.</div>
</div>
@ -276,7 +294,7 @@
<div id="methodNamepw" class="method" style="width:100%;max-width:300px;display:flex;flex-direction:column;gap:8px">
<input id="signinHandle" type="text" placeholder="alice" autocomplete="username" 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="signinPassword" type="password" placeholder="password" autocomplete="current-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%">
<div class="hint">Only works on this device. From a new device, use seed phrase.</div>
<div class="hint">Works on this device, or on any device if you turned on cloud backup. Otherwise use seed phrase.</div>
</div>
<div id="methodSeed" class="method" style="width:100%;max-width:300px;display:none;flex-direction:column;gap:8px">
@ -705,18 +723,24 @@ 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();
});
$("useCreateBtn").addEventListener("click", () => {
$("useCreateBtn").addEventListener("click", async () => {
const handle = $("createHandle").value.trim();
const p1 = $("createPassword").value;
const p2 = $("createPassword2").value;
const wantCloud = $("createCloud").checked;
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 });
await initWithOpts({ create: true, handle, password: p1 });
if (wantCloud && state.ready) {
try {
const r = await bridge.invoke("publishCloudBackup", { password: p1 });
if (!r || !r.ok) console.warn("cloud backup publish failed:", r && r.err);
} catch (e) { console.warn("cloud backup publish threw:", e); }
}
});
// --- unified Sign in button + chip switcher ---
@ -750,9 +774,21 @@ $("signinBtn").addEventListener("click", async () => {
const btn = $("signinBtn"); const prev = btn.textContent;
btn.disabled = true; btn.textContent = "Signing in\u2026";
try {
// Try the local wrap first — fastest path, no network. If this device
// never saw the account, fall back to the cloud backup (opt-in B).
let mnemonic = null;
const r = await bridge.invoke("getBackup", { password });
if (!r || !r.ok) throw new Error((r && r.err) || "wrong password or no local account");
const r2 = await bridge.invoke("init", { mnemonic: r.mnemonic, handle, remember: true });
if (r && r.ok) {
mnemonic = r.mnemonic;
} else {
btn.textContent = "Looking up backup…";
const rc = await bridge.invoke("fetchCloudBackup", { name: handle, password });
if (!rc || !rc.ok) {
throw new Error((rc && rc.err) || (r && r.err) || "wrong password or no account found");
}
mnemonic = rc.mnemonic;
}
const r2 = await bridge.invoke("init", { mnemonic, handle, remember: true });
if (!r2 || !r2.ok) throw new Error((r2 && r2.err) || "sign-in failed");
showApp({ pkHex: r2.pkHex, npub: r2.npub, handle: r2.handle || handle });
} catch (e) {
@ -887,6 +923,19 @@ async function openSettings() {
$("backupReveal").style.display = "none";
$("backupReveal").textContent = "";
err($("backupErr"), "");
// Cloud backup status.
err($("cloudBackupErr"), "");
try {
const r = await bridge.invoke("hasCloudBackup");
const has = !!(r && r.ok && r.hasCloudBackup);
$("cloudBackupStatus").textContent = has
? "Backed up to the cloud" + (r.at ? " · " + new Date(r.at).toLocaleDateString() : "")
: "Not backed up to the cloud";
$("cloudBackupStatus").style.color = has ? "var(--ok,#4fd1a5)" : "var(--mut)";
$("cloudBackupBtn").textContent = has ? "Re-upload backup" : "Back up to cloud";
$("cloudRemoveBtn").style.display = has ? "inline-block" : "none";
} catch {}
$("settingsModal").classList.add("show");
}
@ -972,6 +1021,43 @@ $("revealSeedBtn").addEventListener("click", async () => {
} finally { $("revealSeedBtn").disabled = false; }
});
$("cloudBackupBtn").addEventListener("click", async () => {
const password = $("backupPassword").value;
err($("cloudBackupErr"), "");
if (!password) { err($("cloudBackupErr"), "enter your password above"); return; }
const btn = $("cloudBackupBtn"); const prev = btn.textContent;
btn.disabled = true; btn.textContent = "Uploading…";
try {
const r = await bridge.invoke("publishCloudBackup", { password });
if (!r || !r.ok) throw new Error((r && r.err) || "upload failed");
$("cloudBackupStatus").textContent = "Backed up to the cloud (" + r.accepted + "/" + r.total + " relays)";
$("cloudBackupStatus").style.color = "var(--ok,#4fd1a5)";
$("cloudRemoveBtn").style.display = "inline-block";
btn.textContent = "Re-upload backup";
} catch (e) {
err($("cloudBackupErr"), (e && e.message) || String(e));
btn.textContent = prev;
} finally { btn.disabled = false; }
});
$("cloudRemoveBtn").addEventListener("click", async () => {
err($("cloudBackupErr"), "");
if (!confirm("Remove the cloud backup for this account? You'll no longer be able to sign in with name + password on a new device.")) return;
const btn = $("cloudRemoveBtn"); const prev = btn.textContent;
btn.disabled = true; btn.textContent = "Removing…";
try {
const r = await bridge.invoke("removeCloudBackup");
if (!r || !r.ok) throw new Error((r && r.err) || "remove failed");
$("cloudBackupStatus").textContent = "Not backed up to the cloud";
$("cloudBackupStatus").style.color = "var(--mut)";
$("cloudBackupBtn").textContent = "Back up to cloud";
btn.style.display = "none";
} catch (e) {
err($("cloudBackupErr"), (e && e.message) || String(e));
btn.textContent = prev;
} finally { btn.disabled = false; }
});
$("search").addEventListener("input", renderContacts);
$("backBtn").addEventListener("click", () => {
state.selected = null;

View file

@ -321,6 +321,23 @@ export async function buildPhoneClaimEvent({ sk, phone } = {}) {
}
/** Deletion event (NIP-09, kind:5) that retracts earlier claim events by id. */
// NIP-78 kind:30078 parameterised replaceable event carrying a
// password-wrapped mnemonic. d-tag = `hermes.backup.v1/<16-byte hex of
// sha256(bareName)>` so a client can locate it from name + password alone.
// Content stays opaque to relays — only AES-GCM + scrypt protect the seed.
export const BACKUP_DTAG_PREFIX = "hermes.backup.v1/";
export function buildBackupEvent({ sk, dtag, content } = {}) {
if (!sk) throw new Error("sk required");
if (!dtag) throw new Error("dtag required");
return finalizeEvent({
kind: 30078,
tags: [["d", dtag]],
content: typeof content === "string" ? content : JSON.stringify(content || {}),
created_at: Math.floor(Date.now() / 1000),
}, sk);
}
export function buildPhoneRetractEvent({ sk, eventIds } = {}) {
if (!sk || !Array.isArray(eventIds) || !eventIds.length) throw new Error("sk + eventIds required");
return finalizeEvent({

102
main.js
View file

@ -10784,6 +10784,108 @@ global._hermesAPI = {
},
hasBackup: () => hOk({ hasBackup: !!hermesLoadWrappedMnemonic() }),
// ---- cloud backup (opt-in B) ---------------------------------------------
// Publishes the password-wrapped mnemonic as a NIP-78 kind:30078 event
// keyed by d-tag = "hermes.backup.v1/<sha256(bareName)>". On a new device
// the user enters the same name + password; the client queries relays for
// the event, decrypts the wrap, and signs in without ever typing the seed
// phrase. Each ciphertext is encrypted with scrypt + aes-256-gcm (same
// params as hermesWrapMnemonic) so the password strength IS the backup
// strength — relays store opaque bytes.
publishCloudBackup: async ({ password } = {}) => {
if (!hermesState) return hErr("not initialised");
if (!password) return hErr("password required");
if (!hermesState.handle) return hErr("this account has no handle set — cloud backup needs one");
const localWrap = hermesLoadWrappedMnemonic();
if (!localWrap) return hErr("no local wrap to publish — only accounts created with a password can be backed up this way");
let mnemonic;
try { mnemonic = hermesUnwrapMnemonic(localWrap, password); }
catch { return hErr("wrong password — doesn't match the one you set at signup"); }
try {
const H = await loadHermesLib();
const sk = Uint8Array.from(Buffer.from(hermesState.skHex, "hex"));
const wrap = hermesWrapMnemonic(mnemonic, password);
const bareName = hermesState.handle.toLowerCase().split(".")[0];
const nameHash = nodeCrypto.createHash("sha256").update(bareName, "utf8").digest("hex").slice(0, 32);
const dtag = H.BACKUP_DTAG_PREFIX + nameHash;
const signed = H.buildBackupEvent({
sk,
dtag,
content: { v: 1, handle: hermesState.handle, wrap },
});
const results = await hermesPublishEvent(signed, HERMES_DEFAULT_RELAYS);
const accepted = results.filter((r) => r.ok).length;
if (!accepted) return hErr("no relay accepted the backup — try again or add more relays");
hermesUpdateIdentityMeta({
cloudBackupAt: new Date().toISOString(),
cloudBackupEventId: signed.id,
});
return hOk({ accepted, total: results.length });
} catch (e) { return hErr(e); }
},
hasCloudBackup: () => {
const meta = hermesLoadIdentityMeta();
return hOk({ hasCloudBackup: !!(meta && meta.cloudBackupAt), at: meta && meta.cloudBackupAt || null });
},
removeCloudBackup: async () => {
if (!hermesState) return hErr("not initialised");
const meta = hermesLoadIdentityMeta();
const evId = meta && meta.cloudBackupEventId;
if (!evId) {
hermesUpdateIdentityMeta({ cloudBackupAt: null, cloudBackupEventId: null });
return hOk({ deleted: false, note: "no tracked backup event; cleared local marker" });
}
try {
const H = await loadHermesLib();
const sk = Uint8Array.from(Buffer.from(hermesState.skHex, "hex"));
const retract = H.buildPhoneRetractEvent({ sk, eventIds: [evId] });
await hermesPublishEvent(retract, HERMES_DEFAULT_RELAYS);
hermesUpdateIdentityMeta({ cloudBackupAt: null, cloudBackupEventId: null });
return hOk({ deleted: true });
} catch (e) { return hErr(e); }
},
// Fetch + decrypt cloud backup for sign-in on a new device.
// Does NOT touch hermesState — the client calls init() with the returned
// mnemonic to actually sign in.
fetchCloudBackup: async ({ name, password } = {}) => {
if (!name || !password) return hErr("name and password required");
try {
const bareName = String(name).toLowerCase().split(".")[0].replace(/^hermes:/, "");
const nameHash = nodeCrypto.createHash("sha256").update(bareName, "utf8").digest("hex").slice(0, 32);
const dtag = "hermes.backup.v1/" + nameHash;
const found = await new Promise((resolve) => {
let best = null;
let pending = HERMES_DEFAULT_RELAYS.length;
const finish = () => resolve(best);
for (const url of HERMES_DEFAULT_RELAYS) {
const ws = new WebSocket(url);
const close = () => { try { ws.close(1000); } catch {}; if (--pending <= 0) finish(); };
const t = setTimeout(close, 5000);
ws.on("open", () => ws.send(JSON.stringify(["REQ", "b-" + Math.random().toString(36).slice(2, 8),
{ kinds: [30078], "#d": [dtag], limit: 1 }])));
ws.on("message", (buf) => {
let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
if (msg[0] === "EVENT" && msg[2] && msg[2].kind === 30078) {
if (!best || msg[2].created_at > best.created_at) best = msg[2];
} else if (msg[0] === "EOSE") { clearTimeout(t); close(); }
});
ws.on("error", () => { clearTimeout(t); close(); });
}
});
if (!found) return hErr("no cloud backup found for \"" + name + "\"");
let payload;
try { payload = JSON.parse(found.content); } catch { return hErr("corrupt backup payload"); }
if (!payload || payload.v !== 1 || !payload.wrap) return hErr("unsupported backup version");
try {
const mnemonic = hermesUnwrapMnemonic(payload.wrap, password);
return hOk({ mnemonic, handle: payload.handle || null });
} catch { return hErr("wrong password"); }
} catch (e) { return hErr(e); }
},
// ---- phone discovery (opt-in) --------------------------------------------
// Publishes a kind:30078 claim event bound to a hash of the phone number,
// signed by the user's Hermes key. The phone number itself is normalised