+ We hash the number locally and publish a Nostr claim for the hash. Anyone who already knows your number can find you — same tradeoff as number-based messengers. Unlink any time.
+
+
Backup
No seed phrase stored for this account. (created-elsewhere / vault / mnemonic signins don't retain the words)
@@ -295,7 +309,7 @@
Start a new conversation
-
+
@@ -756,6 +770,17 @@ async function openSettings() {
$("profileAbout").value = (p && p.about) || "";
} catch {}
+ // Phone discovery state.
+ err($("phoneErr"), "");
+ try {
+ const r = await bridge.invoke("getPhone");
+ const linked = r && r.ok && r.phone;
+ $("phoneHave").style.display = linked ? "block" : "none";
+ $("phoneNone").style.display = linked ? "none" : "block";
+ $("phoneShow").textContent = linked ? r.phone : "—";
+ $("phoneInput").value = "";
+ } catch {}
+
let hasBackup = false;
try {
const r = await bridge.invoke("hasBackup");
@@ -770,6 +795,42 @@ async function openSettings() {
$("settingsModal").classList.add("show");
}
+$("linkPhoneBtn").addEventListener("click", async () => {
+ const phone = $("phoneInput").value.trim();
+ if (!phone) { err($("phoneErr"), "enter a phone number"); return; }
+ err($("phoneErr"), "");
+ $("linkPhoneBtn").disabled = true;
+ const prev = $("linkPhoneBtn").textContent;
+ $("linkPhoneBtn").textContent = "Publishing…";
+ try {
+ const r = await bridge.invoke("setPhone", { phone });
+ if (!r || !r.ok) throw new Error((r && r.err) || "could not link");
+ $("phoneHave").style.display = "block";
+ $("phoneNone").style.display = "none";
+ $("phoneShow").textContent = r.phone;
+ $("linkPhoneBtn").textContent = prev;
+ } catch (e) {
+ err($("phoneErr"), (e && e.message) || String(e));
+ $("linkPhoneBtn").textContent = prev;
+ } finally { $("linkPhoneBtn").disabled = false; }
+});
+
+$("unlinkPhoneBtn").addEventListener("click", async () => {
+ if (!confirm("Unlink your phone? We'll ask relays to delete the claim, but copies that already propagated may linger until the relay expires them.")) return;
+ err($("phoneErr"), "");
+ $("unlinkPhoneBtn").disabled = true;
+ try {
+ const r = await bridge.invoke("clearPhone");
+ if (!r || !r.ok) throw new Error((r && r.err) || "could not unlink");
+ $("phoneHave").style.display = "none";
+ $("phoneNone").style.display = "block";
+ $("phoneShow").textContent = "—";
+ $("phoneInput").value = "";
+ } catch (e) {
+ err($("phoneErr"), (e && e.message) || String(e));
+ } finally { $("unlinkPhoneBtn").disabled = false; }
+});
+
$("saveProfileBtn").addEventListener("click", async () => {
const displayName = $("profileDisplayName").value.trim();
const picture = $("profilePicture").value.trim();
diff --git a/lib/hermes.js b/lib/hermes.js
index f93f327e..6c169759 100644
--- a/lib/hermes.js
+++ b/lib/hermes.js
@@ -240,6 +240,77 @@ export function parseProfileContent(content) {
} catch { return null; }
}
+// ---- phone discovery (opt-in) ----------------------------------------------
+// A user who opts in publishes one Nostr event per phone number they want
+// to be reachable at. The number itself never leaves the device — only a
+// hash is sent. Someone who already knows the number can hash it and query
+// the relay to find the Hermes identity behind it; someone who doesn't has
+// to brute-force a 256-bit space constrained by phone-number plausibility
+// (same honest limit as Signal / WhatsApp without PSI).
+//
+// Event shape: kind:30078 parameterised replaceable, d-tag =
+// `hermes.phone/`, pubkey = the user's Hermes npub. Content is
+// empty JSON — the association IS the (d-tag, pubkey) pair; no payload
+// needed. Deletion is a plain kind:5 referencing the event id.
+//
+// Normalisation: strip everything except digits and a leading +. If the
+// input doesn't start with + we treat it as E.164-missing-prefix and
+// refuse — the UI prompts for country code. One format in, one format
+// hashed; otherwise users with the same number but different formatting
+// wouldn't match.
+export function normalisePhone(input) {
+ const s = String(input || "").trim();
+ if (!s) throw new Error("empty phone");
+ // Keep + and digits only. Users often paste "+1 (555) 123-4567" — all
+ // separators drop silently; a leading "00" is treated as "+" for Europe
+ // dialing habits.
+ const cleaned = s.replace(/[^\d+]/g, "");
+ if (!cleaned) throw new Error("empty phone");
+ let normalised;
+ if (cleaned.startsWith("+")) normalised = "+" + cleaned.slice(1).replace(/^0+/, "");
+ else if (cleaned.startsWith("00")) normalised = "+" + cleaned.slice(2).replace(/^0+/, "");
+ else throw new Error("include a + country code, e.g. +15551234567");
+ if (!/^\+[1-9]\d{6,14}$/.test(normalised)) {
+ throw new Error("phone must be 7-15 digits after the country code");
+ }
+ return normalised;
+}
+
+/** SHA-256(E.164) → base64url, truncated to 32 chars for d-tag compactness.
+ * Collision space ~2^192 — still infeasible to collide a real-world phone,
+ * while keeping the d-tag short in relay-filter payloads. */
+export async function hashPhone(input) {
+ const e164 = normalisePhone(input);
+ const buf = await wc.subtle.digest("SHA-256", enc.encode(e164));
+ const b64 = Buffer.from(new Uint8Array(buf)).toString("base64")
+ .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+ return b64.slice(0, 32);
+}
+
+export const PHONE_DTAG_PREFIX = "hermes.phone/";
+
+export async function buildPhoneClaimEvent({ sk, phone } = {}) {
+ if (!sk) throw new Error("sk required");
+ const hash = await hashPhone(phone);
+ return finalizeEvent({
+ kind: 30078,
+ content: "{}",
+ tags: [["d", PHONE_DTAG_PREFIX + hash]],
+ created_at: Math.floor(Date.now() / 1000),
+ }, sk);
+}
+
+/** Deletion event (NIP-09, kind:5) that retracts earlier claim events by id. */
+export function buildPhoneRetractEvent({ sk, eventIds } = {}) {
+ if (!sk || !Array.isArray(eventIds) || !eventIds.length) throw new Error("sk + eventIds required");
+ return finalizeEvent({
+ kind: 5,
+ content: "retracting hermes phone claim",
+ tags: eventIds.map((id) => ["e", id]),
+ created_at: Math.floor(Date.now() / 1000),
+ }, sk);
+}
+
// ---- NIP-17 wrap / unwrap --------------------------------------------------
const KIND_CHAT = 14;
const KIND_SEAL = 13;
diff --git a/main.js b/main.js
index 75736448..e26588b1 100644
--- a/main.js
+++ b/main.js
@@ -9885,6 +9885,35 @@ async function hermesPublishEvent(ev, relays, { timeoutMs = 8000 } = {}) {
// right affordance.
async function hermesResolveTo(input, { defaultRelays } = {}) {
const H = await loadHermesLib();
+ // Phone-shaped inputs skip parseHandle (which rejects "+") and hit the
+ // discovery relay instead. Returns the first match. Users on multiple
+ // devices land on the same npub, so collisions in the kind:30078 space
+ // are expected and the newest-wins resolution inside findByPhones picks
+ // the user's current identity.
+ const raw = String(input || "").trim();
+ if (/^(?:\+|00)[\d\s().-]{6,}$/.test(raw)) {
+ const dtag = H.PHONE_DTAG_PREFIX + (await H.hashPhone(raw));
+ 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", "r-" + 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) throw new Error(`no Hermes identity linked to ${raw}`);
+ return { pkHex: found.pubkey, relays: (defaultRelays || HERMES_DEFAULT_RELAYS).slice(), canonicalName: null };
+ }
const parsed = H.parseHandle(input);
if (parsed.kind === "hex") {
return { pkHex: parsed.value, relays: (defaultRelays || HERMES_DEFAULT_RELAYS).slice(), canonicalName: null };
@@ -10020,6 +10049,26 @@ function hermesSaveIdentity({ skHex, pkHex, npub, source, handle }) {
}
}
+function hermesLoadIdentityMeta() {
+ try {
+ if (!fs.existsSync(hermesIdentityMetaPath())) return null;
+ return JSON.parse(fs.readFileSync(hermesIdentityMetaPath(), "utf8"));
+ } catch { return null; }
+}
+function hermesUpdateIdentityMeta(patch) {
+ const cur = hermesLoadIdentityMeta() || {};
+ const next = { ...cur, ...patch };
+ try {
+ hermesEnsureDir(hermesIdentityDir());
+ fs.writeFileSync(hermesIdentityMetaPath() + ".tmp", JSON.stringify(next, null, 2));
+ fs.renameSync(hermesIdentityMetaPath() + ".tmp", hermesIdentityMetaPath());
+ return next;
+ } catch (e) {
+ console.error("[hermes] identity meta update failed:", e.message);
+ return cur;
+ }
+}
+
function hermesLoadIdentity() {
try {
if (!fs.existsSync(hermesSealedKeyPath()) || !fs.existsSync(hermesIdentityMetaPath())) return null;
@@ -10554,6 +10603,104 @@ global._hermesAPI = {
} catch { return hErr("wrong password"); }
},
hasBackup: () => hOk({ hasBackup: !!hermesLoadWrappedMnemonic() }),
+
+ // ---- 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
+ // and hashed client-side; only the hash + user's npub leave the device.
+ // Clear-text phone is kept locally so the user can see what they linked
+ // and clear it; it never goes on the wire.
+ getPhone: () => {
+ const meta = hermesLoadIdentityMeta();
+ return hOk({ phone: (meta && meta.phone) || null, eventIds: (meta && meta.phoneEventIds) || [] });
+ },
+ setPhone: async ({ phone } = {}) => {
+ if (!hermesState) return hErr("not initialised");
+ try {
+ const H = await loadHermesLib();
+ const e164 = H.normalisePhone(phone); // throws on bad input
+ const sk = Uint8Array.from(Buffer.from(hermesState.skHex, "hex"));
+ const ev = await H.buildPhoneClaimEvent({ sk, phone: e164 });
+ const results = await hermesPublishEvent(ev, HERMES_DEFAULT_RELAYS);
+ const accepted = results.filter((r) => r.ok).length;
+ if (accepted === 0) return hErr("no relay accepted the claim — try again");
+ // Persist the plaintext phone locally so the user can see what's linked
+ // and retract it later. The event id goes with it so retraction can
+ // reference the exact wire event we published.
+ hermesUpdateIdentityMeta({
+ phone: e164,
+ phoneEventIds: [ev.id, ...((hermesLoadIdentityMeta()?.phoneEventIds) || [])].slice(0, 8),
+ });
+ return hOk({ phone: e164, accepted, total: results.length });
+ } catch (e) { return hErr(e); }
+ },
+ clearPhone: async () => {
+ if (!hermesState) return hErr("not initialised");
+ try {
+ const meta = hermesLoadIdentityMeta() || {};
+ const eventIds = meta.phoneEventIds || [];
+ if (!eventIds.length && !meta.phone) return hOk({ cleared: false, note: "no phone linked" });
+ if (eventIds.length) {
+ const H = await loadHermesLib();
+ const sk = Uint8Array.from(Buffer.from(hermesState.skHex, "hex"));
+ const ev = H.buildPhoneRetractEvent({ sk, eventIds });
+ await hermesPublishEvent(ev, HERMES_DEFAULT_RELAYS);
+ // Even if relays reject the deletion we clear local state — the
+ // user told us to forget it, so forget it. The claim may linger on
+ // relays until they expire 30078s; that's a Nostr property, not a
+ // bug we can fix here.
+ }
+ hermesUpdateIdentityMeta({ phone: null, phoneEventIds: [] });
+ return hOk({ cleared: true });
+ } catch (e) { return hErr(e); }
+ },
+ findByPhones: async ({ phones } = {}) => {
+ if (!Array.isArray(phones) || !phones.length) return hErr("phones[] required");
+ try {
+ const H = await loadHermesLib();
+ // Hash every input; silently drop anything that doesn't normalise so
+ // one bad row doesn't torpedo a bulk vCard import. We DO keep a map
+ // from hash → original so the UI can show "+1 555 123 4567 → bob.erm".
+ const map = new Map();
+ for (const p of phones) {
+ try { const h = await H.hashPhone(p); map.set(h, p); } catch { /* skip */ }
+ }
+ if (!map.size) return hErr("no valid phones to look up");
+ const dtags = Array.from(map.keys()).map((h) => H.PHONE_DTAG_PREFIX + h);
+ // One REQ per relay with the full d-tag list; collect responses, newest
+ // wins per (pk, d). Short timeout so bulk lookups don't hang.
+ const results = await Promise.all(HERMES_DEFAULT_RELAYS.map((url) => new Promise((resolve) => {
+ const ws = new WebSocket(url);
+ const found = []; // {dtag, pubkey, created_at}
+ const settle = () => { try { ws.close(1000); } catch {} resolve(found); };
+ const t = setTimeout(settle, 5000);
+ ws.on("open", () => ws.send(JSON.stringify(["REQ", "ph-" + Math.random().toString(36).slice(2, 8),
+ { kinds: [30078], "#d": dtags, limit: dtags.length * 2 }])));
+ ws.on("message", (buf) => {
+ let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
+ if (msg[0] === "EVENT" && msg[2] && msg[2].kind === 30078) {
+ const d = (msg[2].tags.find((t) => t[0] === "d") || [])[1];
+ if (d && d.startsWith(H.PHONE_DTAG_PREFIX)) {
+ found.push({ dtag: d, pubkey: msg[2].pubkey, created_at: msg[2].created_at });
+ }
+ } else if (msg[0] === "EOSE") { clearTimeout(t); settle(); }
+ });
+ ws.on("error", () => { clearTimeout(t); settle(); });
+ })));
+ // Collapse per (dtag) → newest pubkey wins
+ const bestByDtag = new Map();
+ for (const batch of results) for (const r of batch) {
+ const prev = bestByDtag.get(r.dtag);
+ if (!prev || r.created_at > prev.created_at) bestByDtag.set(r.dtag, r);
+ }
+ const matches = [];
+ for (const [dtag, hit] of bestByDtag) {
+ const hash = dtag.slice(H.PHONE_DTAG_PREFIX.length);
+ matches.push({ phone: map.get(hash) || null, pkHex: hit.pubkey, hash });
+ }
+ return hOk({ matches, queried: map.size });
+ } catch (e) { return hErr(e); }
+ },
// Profile (Nostr kind:0) — orthogonal to the on-chain handle. Users can
// leave this empty; a filled profile just makes incoming messages render
// with a nicer display name + picture.