diff --git a/bundled-addons/hermes/addon.json b/bundled-addons/hermes/addon.json
index 54ee737a..a730d9bf 100644
--- a/bundled-addons/hermes/addon.json
+++ b/bundled-addons/hermes/addon.json
@@ -1,7 +1,7 @@
{
"id": "hermes",
"name": "Hermes",
- "version": "0.2.6",
+ "version": "0.2.8",
"description": "Message anyone by their on-chain name. End-to-end encrypted DMs over Nostr (NIP-17), identity derived from your wallet seed.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjM3OS45NCA5OC40MiA1NjIuMzUgNTYyLjM1IiByb2xlPSJpbWciIGFyaWEtbGFiZWw9Ikhlcm1lcyI+PHRpdGxlPkhlcm1lczwvdGl0bGU+PHBhdGggZmlsbD0iIzM4QkRGOCIgZD0iTTU0Ny42MTUxMTIsNTEwLjM4MDk1MSAgIEM1NDEuMjU5Mzk5LDUwMC43OTY2OTIgNTMzLjEyNjM0Myw0OTcuMzAwMDQ5IDUyMS41MjY4NTUsNDk2LjQwMDgxOCAgIEM1MDAuNDAxNTgxLDQ5NC43NjMyMTQgNDgzLjk1ODQ2Niw0ODIuODgwMjgwIDQ3MS4zODMyMDksNDY1Ljk4ODMxMiAgIEM0NjkuMjI1Njc3LDQ2My4wOTAwODggNDY3LjA3NjE0MSw0NjIuMTA5NzcyIDQ2My41NDAxOTIsNDYyLjE4OTM5MiAgIEM0NDEuNDcwMjc2LDQ2Mi42ODYzMTAgNDIyLjI4NjY1Miw0NTUuMjIyODM5IDQwNi41NDc3OTEsNDM5LjgzNzk1MiAgIEMzOTcuNDI0MjU1LDQzMC45MTk1ODYgMzg5LjEzMjA1MCw0MjEuMTUwNzU3IDM3OS45Mzk3ODksNDExLjE5NTAzOCAgIEMzODIuODY1MTQzLDQwOS44OTEzMjcgMzg0Ljc0MzMxNyw0MDguOTQ3MTEzIDM4Ni42OTY2NTUsNDA4LjE5OTMxMCAgIEM0NDkuMTY1MjIyLDM4NC4yODQ1NDYgNTExLjY3NTIwMSwzNjAuNDc3Mjk1IDU3NC4wODMwMDgsMzM2LjQwNTIxMiAgIEM1ODQuODMyNTgxLDMzMi4yNTg4NTAgNTk1LjI3OTU0MSwzMzEuMjkwMjUzIDYwNi40NjIwOTcsMzM2LjI4MzE0MiAgIEM2MDcuNDI3OTc5LDMyMy44NTU1OTEgNjE1LjQ5ODEwOCwzMTkuMjUzMzg3IDYyNS41NDM0NTcsMzE1LjQ1NTA3OCAgIEM3MDIuNDMxODI0LDI4Ni4zODIwMTkgNzc5LjE4MjQ5NSwyNTYuOTQ0ODI0IDg1NS45OTEyMTEsMjI3LjY2MDcwNiAgIEM4NjkuODU1MTY0LDIyMi4zNzQ5MDggODc5LjAwMjU2MywyMjYuODExNzgzIDg4NC4xNjA3NjcsMjQwLjg5MzIwNCAgIEM5MDEuODQ1NzY0LDI4OS4xNzEyNjUgOTE5LjU3MDA2OCwzMzcuNDM0OTY3IDkzNy4zMjA2MTgsMzg1LjY4ODk2NSAgIEM5NDIuMjg0OTczLDM5OS4xODQ0NzkgOTM4LjIwOTQ3Myw0MDguOTc5MTI2IDkyNC44MzU1MTAsNDE0LjA3NDY0NiAgIEM4NTYuNTY5NjQxLDQ0MC4wODQwNzYgNzg4LjI2MTc4MCw0NjUuOTgzMTg1IDcxOS45NjYxMjUsNDkxLjkxNDM5OCAgIEM3MTAuMDA4MTc5LDQ5NS42OTUzMTIgNzAwLjA3OTc3Myw0OTkuNTU5ODc1IDY5MC4wNjExNTcsNTAzLjE3MzU4NCAgIEM2NzcuODY3ODU5LDUwNy41NzE2NTUgNjY4LjMyMjMyNyw1MDMuNDAzMjkwIDY2My42MjY4MzEsNDkxLjAzMzA1MSAgIEM2NTIuNTg0MjkwLDQ2MS45NDE5NTYgNjQxLjkwMzA3Niw0MzIuNzEzNzQ1IDYzMS4wNjY4MzMsNDAzLjU0NDM0MiAgIEM2MjkuMzg2MzUzLDM5OS4wMjA4NzQgNjI3LjM0MDM5MywzOTQuNjA2MTcxIDYyNi4wMTUxMzcsMzg5Ljk4MzAzMiAgIEM2MjIuMzYyNjEwLDM3Ny4yNDA5OTcgNjEzLjEwMzE0OSwzNjkuNzA2Njk2IDYwMi4wOTc3MTcsMzYzLjg3MzUzNSAgIEM1OTMuNjMwNzM3LDM1OS4zODU3NzMgNTg1LjAzNjAxMSwzNTguNjY1NDA1IDU3NS42ODIxMjksMzYyLjMwNTcyNSAgIEM1MjYuODM4NTYyLDM4MS4zMTQ0ODQgNDc3Ljg1NTQ5OSwzOTkuOTY0ODc0IDQyOC45MjU3NTEsNDE4Ljc1MjQxMSAgIEM0MjcuMjQ5NTczLDQxOS4zOTU5OTYgNDI1LjYyNDYzNCw0MjAuMTcyOTEzIDQyMy4yMTIzNzIsNDIxLjIxODU5NyAgIEM0MjkuOTQ3Mzg4LDQyOC4xNjEwMTEgNDM3LjUxNDUyNiw0MzIuMzQ2NjQ5IDQ0NS45NjYzMDksNDM0LjkzNjc5OCAgIEM0NTguNjk2Nzc3LDQzOC44MzgxNjUgNDcxLjA4NTA1Miw0MzcuODEyNTAwIDQ4My41NjE4OTAsNDMyLjk0NTE2MCAgIEM1MjIuNjIzMjMwLDQxNy43MDY4MTggNTYxLjgzNTY5Myw0MDIuODU1NjIxIDYwMC45OTQyNjMsMzg3Ljg2NjYzOCAgIEM2MDIuODIxMTY3LDM4Ny4xNjczODkgNjA0LjY5MzI5OCwzODYuNTg2MjEyIDYwNy4xNDE1NDEsMzg1Ljc0NDc1MSAgIEM2MDkuODk2NjA2LDM5My4zMzc5NTIgNjEyLjU0NDQzNCw0MDAuNjM1ODAzIDYxNS40NzYzMTgsNDA4LjcxNjQ5MiAgIEM1NzUuMjY5MDQzLDQyNC4xNDczNjkgNTM1LjMwNjU4MCw0MzkuNDg0MzE0IDQ5NC43MzA5MjcsNDU1LjA1NjU0OSAgIEM1MDIuOTg1MzUyLDQ2NC43NDAxNDMgNTEyLjkxNTgzMyw0NzAuMDMzMjY0IDUyNC43OTA1ODgsNDcxLjk1NDQ5OCAgIEM1MzMuMjIwNTgxLDQ3My4zMTg0MjAgNTQxLjMyNDM0MSw0NzIuMjk0MjIwIDU0OS4yOTU3MTUsNDY5LjIxMjg5MSAgIEM1NzMuNTA3MDgwLDQ1OS44NTQwMDQgNTk3Ljc2ODE4OCw0NTAuNjIzNzE4IDYyMi4wMTU0NDIsNDQxLjM1Nzk0MSAgIEM2MjMuNTMzOTk3LDQ0MC43Nzc2NDkgNjI1LjEwMDQwMyw0NDAuMzIyNDc5IDYyNy4xMTMwOTgsNDM5LjY1MzI5MCAgIEM2MjkuOTk2ODg3LDQ0Ny4yOTgxNTcgNjMyLjc4MjI4OCw0NTQuNjgyMDk4IDYzNS43MTI0NjMsNDYyLjQ0OTg2MCAgIEM2MTEuNDMzMjg5LDQ3MS44MTI0MzkgNTg3LjYxNjAyOCw0ODAuOTk2ODg3IDU2My4wMjM2MjEsNDkwLjQ4MDI1NSAgIEM1NzUuMzY0ODY4LDUwMy40MDc3NDUgNTg5LjU5NDQyMSw1MTAuMjkwNDk3IDYwNi40NjEzMDQsNTA3LjA4MDk2MyAgIEM2MTguNDEyNTM3LDUwNC44MDY3NjMgNjI5Ljc3ODEzNyw0OTkuNDUxOTA0IDY0MS40MDE1NTAsNDk1LjQ2MTYwOSAgIEM2NDMuMTExODE2LDQ5NC44NzQ0ODEgNjQ0LjgwMjE4NSw0OTQuMjI5MzA5IDY0NS44MDA2NTksNDkzLjg2NjA1OCAgIEM2NTEuMjczMzc2LDUwMC43OTA3MTAgNjU2LjQ0MDMwOCw1MDcuMzI4NDAwIDY2Mi4xMTE1MTEsNTE0LjUwNDE1MCAgIEM2NDUuODA4NDcyLDUyMC4xNTAyNjkgNjI5LjQwODM4Niw1MjcuNTgyOTQ3IDYxMi4yMDU2ODgsNTMxLjM2OTYyOSAgIEM1ODcuNDY4MTQwLDUzNi44MTUwNjMgNTY2LjAyNDA0OCw1MjcuNzgzNTA4IDU0Ny42MTUxMTIsNTEwLjM4MDk1MSAgTTkwOC45MzU5MTMsMzc5LjIwMDg2NyAgIEM4OTUuNTAwODU0LDM0Mi42NDQzNzkgODgyLjA2NTczNSwzMDYuMDg3ODkxIDg2OC4yMTIzNDEsMjY4LjM5MzE1OCAgIEM4NTguNzY4NzM4LDI4NS4zNjY4MjEgODQ5Ljk4NjAyMywzMDEuMDMwMzA0IDg0MS4zMjUzNzgsMzE2Ljc2MTAxNyAgIEM4MzUuMzA2Mzk2LDMyNy42OTM1NDIgODI5LjQ1NTI2MSwzMzguNzE4NTA2IDgyMy4wNjUzMDgsMzUwLjIwMTAxOSAgIEM4MTEuNzgxNzM4LDM2OS45Njc4NjUgODAwLjQ5ODE2OSwzODkuNzM0NzQxIDc4OS4wNjQxNDgsNDA5Ljc2NTE5OCAgIEM3MzkuMzY1OTY3LDM5MS44NDk2MDkgNjkwLjAyNTIwOCwzNzQuMDYyODk3IDYzOS43NDg1OTYsMzU1LjkzODg0MyAgIEM2NDAuNjgzNTMzLDM1OC42ODg3ODIgNjQxLjE3Njk0MSwzNjAuMjUyMjI4IDY0MS43NDMyMjUsMzYxLjc4ODgxOCAgIEM2NDkuNzM5MDc1LDM4My40ODM3OTUgNjU3LjY2NTI4Myw0MDUuMjA0ODk1IDY2NS43NzU2MzUsNDI2Ljg1NzAyNSAgIEM2NzEuMzcwODUwLDQ0MS43OTQ2NDcgNjc3LjA1ODcxNiw0NTYuNzAyNjM3IDY4My4wNDUyMjcsNDcxLjQ4NjE0NSAgIEM2ODUuMzg2ODQxLDQ3Ny4yNjgzNzIgNjg4LjA1MTU3NSw0NzguMTc0ODk2IDY5NC4wNjgyMzcsNDc1Ljk4NjA4NCAgIEM3MDAuOTU3Mjc1LDQ3My40Nzk4NTggNzA3LjY2NTc3MSw0NzAuNDgyNDUyIDcxNC41MTU2MjUsNDY3Ljg2Mjk3NiAgIEM3NzYuNDU4NjE4LDQ0NC4xNzUyMDEgODM4LjQxOTMxMiw0MjAuNTMzNTM5IDkwMC4zNjM2NDcsMzk2Ljg0OTIxMyAgIEM5MDguNjA4NzY1LDM5My42OTY3MTYgOTEyLjAwMTE2MCwzODYuODczNDc0IDkwOC45MzU5MTMsMzc5LjIwMDg2NyAgTTgyMS45NTg0MzUsMzAyLjQyMjg4MiAgIEM4MjkuOTQ3MjY2LDI4Ny45MzE3MDIgODM3LjkzNjE1NywyNzMuNDQwNTIxIDg0Ni42MzY1OTcsMjU3LjY1ODY2MSAgIEM3ODAuMTM1NDM3LDI4My4wMTIyMDcgNzE1LjAxMDA3MSwzMDcuODQxMjc4IDY0OS44ODQ2NDQsMzMyLjY3MDM0OSAgIEM2NDkuOTAxOTc4LDMzMy4wNjk2MTEgNjQ5LjkxOTM3MywzMzMuNDY4OTAzIDY0OS45MzY3MDcsMzMzLjg2ODE5NSAgIEM2OTIuNjc0MTMzLDM0OS4yNjY3NTQgNzM1LjQxMTU2MCwzNjQuNjY1MzE0IDc3OC40NDI0NDQsMzgwLjE2OTYxNyAgIEM3OTMuMDA5ODI3LDM1NC4xNDUzMjUgODA3LjMwNTA1NCwzMjguNjA3MjM5IDgyMS45NTg0MzUsMzAyLjQyMjg4MiAgeiIvPjxwYXRoIGZpbGw9IiMwNjExMWEiIGQ9Ik05MDguOTQ3NzU0LDM3OS45MzkyNDAgICBDOTEyLjAwMTE2MCwzODYuODczNDc0IDkwOC42MDg3NjUsMzkzLjY5NjcxNiA5MDAuMzYzNjQ3LDM5Ni44NDkyMTMgICBDODM4LjQxOTMxMiw0MjAuNTMzNTM5IDc3Ni40NTg2MTgsNDQ0LjE3NTIwMSA3MTQuNTE1NjI1LDQ2Ny44NjI5NzYgICBDNzA3LjY2NTc3MSw0NzAuNDgyNDUyIDcwMC45NTcyNzUsNDczLjQ3OTg1OCA2OTQuMDY4MjM3LDQ3NS45ODYwODQgICBDNjg4LjA1MTU3NSw0NzguMTc0ODk2IDY4NS4zODY4NDEsNDc3LjI2ODM3MiA2ODMuMDQ1MjI3LDQ3MS40ODYxNDUgICBDNjc3LjA1ODcxNiw0NTYuNzAyNjM3IDY3MS4zNzA4NTAsNDQxLjc5NDY0NyA2NjUuNzc1NjM1LDQyNi44NTcwMjUgICBDNjU3LjY2NTI4Myw0MDUuMjA0ODk1IDY0OS43MzkwNzUsMzgzLjQ4Mzc5NSA2NDEuNzQzMjI1LDM2MS43ODg4MTggICBDNjQxLjE3Njk0MSwzNjAuMjUyMjI4IDY0MC42ODM1MzMsMzU4LjY4ODc4MiA2MzkuNzQ4NTk2LDM1NS45Mzg4NDMgICBDNjkwLjAyNTIwOCwzNzQuMDYyODk3IDczOS4zNjU5NjcsMzkxLjg0OTYwOSA3ODkuMDY0MTQ4LDQwOS43NjUxOTggICBDODAwLjQ5ODE2OSwzODkuNzM0NzQxIDgxMS43ODE3MzgsMzY5Ljk2Nzg2NSA4MjMuNjA0Njc1LDM1MC4wNzQ0MDIgICBDODI1LjA4Njg1MywzNDkuMzYyMjc0IDgyNi4zNjM2NDcsMzQ4Ljk4ODc3MCA4MjYuODk5MzUzLDM0OC4xNDQ4NjcgICBDODI3Ljg1ODY0MywzNDYuNjMzNDUzIDgyOC4yMjU5NTIsMzQ0Ljc1OTAwMyA4MjkuMDkzNTY3LDM0My4xNzQ4MzUgICBDODM5LjMwNzY3OCwzMjQuNTI0NTY3IDg0OS41NTEwMjUsMzA1Ljg5MDMyMCA4NTkuODM4NzQ1LDI4Ny4yODA1NzkgICBDODYxLjIwMjIwOSwyODQuODE0MDU2IDg2Mi43MDE1OTksMjgyLjM5MTA1MiA4NjQuNDA1NDU3LDI4MC4xNTEzOTggICBDODY3LjMyODEyNSwyNzYuMzA5NzUzIDg2OC41NjI4MDUsMjc2LjQyODgwMiA4NzAuMjk3MTE5LDI4MC45NDUxMjkgICBDODc3LjY2OTU1NiwzMDAuMTQ0MTA0IDg4NC45ODcxODMsMzE5LjM2NDkyOSA4OTIuMTc4NTI4LDMzOC42MzIzMjQgICBDODk2Ljg4MDE4OCwzNTEuMjI5MDA0IDkwMS4yODMyNjQsMzYzLjkzNjk1MSA5MDUuOTY1MDg4LDM3Ni41NDExNjggICBDOTA2LjQ1NTM4MywzNzcuODYxMDUzIDkwNy45Mjk5MzIsMzc4LjgxNTMzOCA5MDguOTQ3NzU0LDM3OS45MzkyNDAgIHoiLz48cGF0aCBmaWxsPSIjMDYxMTFhIiBkPSJNODIxLjc3OTM1OCwzMDIuNzQ2MDMzICAgQzgwNy4zMDUwNTQsMzI4LjYwNzIzOSA3OTMuMDA5ODI3LDM1NC4xNDUzMjUgNzc4LjQ0MjQ0NCwzODAuMTY5NjE3ICAgQzczNS40MTE1NjAsMzY0LjY2NTMxNCA2OTIuNjc0MTMzLDM0OS4yNjY3NTQgNjQ5LjkzNjcwNywzMzMuODY4MTk1ICAgQzY0OS45MTkzNzMsMzMzLjQ2ODkwMyA2NDkuOTAxOTc4LDMzMy4wNjk2MTEgNjQ5Ljg4NDY0NCwzMzIuNjcwMzQ5ICAgQzcxNS4wMTAwNzEsMzA3Ljg0MTI3OCA3ODAuMTM1NDM3LDI4My4wMTIyMDcgODQ2LjYzNjU5NywyNTcuNjU4NjYxICAgQzgzNy45MzYxNTcsMjczLjQ0MDUyMSA4MjkuOTQ3MjY2LDI4Ny45MzE3MDIgODIxLjc3OTM1OCwzMDIuNzQ2MDMzICB6Ii8+PHBhdGggZmlsbD0iIzA2MTExYSIgZD0iTTkwOC45NDE4MzMsMzc5LjU3MDA2OCAgIEM5MDcuOTI5OTMyLDM3OC44MTUzMzggOTA2LjQ1NTM4MywzNzcuODYxMDUzIDkwNS45NjUwODgsMzc2LjU0MTE2OCAgIEM5MDEuMjgzMjY0LDM2My45MzY5NTEgODk2Ljg4MDE4OCwzNTEuMjI5MDA0IDg5Mi4xNzg1MjgsMzM4LjYzMjMyNCAgIEM4ODQuOTg3MTgzLDMxOS4zNjQ5MjkgODc3LjY2OTU1NiwzMDAuMTQ0MTA0IDg3MC4yOTcxMTksMjgwLjk0NTEyOSAgIEM4NjguNTYyODA1LDI3Ni40Mjg4MDIgODY3LjMyODEyNSwyNzYuMzA5NzUzIDg2NC40MDU0NTcsMjgwLjE1MTM5OCAgIEM4NjIuNzAxNTk5LDI4Mi4zOTEwNTIgODYxLjIwMjIwOSwyODQuODE0MDU2IDg1OS44Mzg3NDUsMjg3LjI4MDU3OSAgIEM4NDkuNTUxMDI1LDMwNS44OTAzMjAgODM5LjMwNzY3OCwzMjQuNTI0NTY3IDgyOS4wOTM1NjcsMzQzLjE3NDgzNSAgIEM4MjguMjI1OTUyLDM0NC43NTkwMDMgODI3Ljg1ODY0MywzNDYuNjMzNDUzIDgyNi44OTkzNTMsMzQ4LjE0NDg2NyAgIEM4MjYuMzYzNjQ3LDM0OC45ODg3NzAgODI1LjA4Njg1MywzNDkuMzYyMjc0IDgyMy44MzY1NDgsMzQ5LjgyNTAxMiAgIEM4MjkuNDU1MjYxLDMzOC43MTg1MDYgODM1LjMwNjM5NiwzMjcuNjkzNTQyIDg0MS4zMjUzNzgsMzE2Ljc2MTAxNyAgIEM4NDkuOTg2MDIzLDMwMS4wMzAzMDQgODU4Ljc2ODczOCwyODUuMzY2ODIxIDg2OC4yMTIzNDEsMjY4LjM5MzE1OCAgIEM4ODIuMDY1NzM1LDMwNi4wODc4OTEgODk1LjUwMDg1NCwzNDIuNjQ0Mzc5IDkwOC45NDE4MzMsMzc5LjU3MDA2OCAgeiIvPjwvc3ZnPgo=",
diff --git a/bundled-addons/hermes/index.js b/bundled-addons/hermes/index.js
index 019c9564..9c187ad7 100644
--- a/bundled-addons/hermes/index.js
+++ b/bundled-addons/hermes/index.js
@@ -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",
diff --git a/bundled-addons/hermes/panel.html b/bundled-addons/hermes/panel.html
index cc7d9fc1..799b350b 100644
--- a/bundled-addons/hermes/panel.html
+++ b/bundled-addons/hermes/panel.html
@@ -234,6 +234,20 @@
+
+
+
+
Cloud backup
+
+
+
+
+
+
+
Lets you sign in with name + password on another device. Your password is the key; relays only see opaque ciphertext.
+
@@ -258,6 +272,10 @@
+
You'll get a handle like alice.erm. We'll generate a seed phrase — back it up in Settings.
@@ -276,7 +294,7 @@
@@ -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;
diff --git a/lib/hermes.js b/lib/hermes.js
index a0b5e848..70f44531 100644
--- a/lib/hermes.js
+++ b/lib/hermes.js
@@ -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({
diff --git a/main.js b/main.js
index 31df4e27..4509be23 100644
--- a/main.js
+++ b/main.js
@@ -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/". 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