hermes: phone discovery (opt-in), lookup contacts by number

Users can link one or more phone numbers to their Hermes identity by
publishing a kind:30078 claim event whose d-tag is a SHA-256 hash of the
E.164 number. The number itself never leaves the device. Add-contact and
message-send now also accept a +phone input directly — the client hashes it
and queries relays for the matching Hermes identity.

- lib/hermes.js: normalisePhone(str) strips separators + a leading 00
  into E.164, rejects anything that doesn't fit +[country][7-15 digits].
  hashPhone(str) → base64url-truncated-to-32 SHA-256 digest, used as the
  d-tag suffix under PHONE_DTAG_PREFIX. buildPhoneClaimEvent /
  buildPhoneRetractEvent emit the signed kind:30078 / NIP-09 kind:5.
- main.js: _hermesAPI.getPhone / setPhone / clearPhone / findByPhones.
  Plaintext phone persists in the identity meta (locally only) so the
  user can see what's linked and retract it; event ids are kept so a
  retract can reference the exact wire event. hermesResolveTo takes a
  phone-shaped input and does the discovery lookup transparently, so
  contactsAdd and send accept phones without a new entry point.
- panel.html: Settings → Phone discovery (link / unlink + privacy note),
  add-contact placeholder now lists +phone alongside the other forms.

Privacy stance stated in the UI: "anyone who already knows your number
can find you — same tradeoff as number-based messengers." No PSI tricks
yet; plain hash. Users who don't want it just don't link, which is the
default. Future slice can add a pepper if operators want to narrow the
brute-force window.
This commit is contained in:
Local Dev 2026-10-21 17:21:16 +03:00
parent b5b3e5c1b2
commit 5e27a1b53d
4 changed files with 281 additions and 1 deletions

View file

@ -20,6 +20,7 @@ const { ipcMain } = require("electron");
const PANEL_METHODS = [
"status", "canUseVault", "close", "init", "signOut", "getBackup", "hasBackup",
"getProfile", "updateProfile",
"getPhone", "setPhone", "clearPhone", "findByPhones",
"inbox", "send", "markThreadRead",
"contactsList", "contactsAdd", "contactsUpdate", "contactsRemove",
];

View file

@ -210,6 +210,20 @@
<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">Phone discovery</div>
<div id="phoneHave" style="display:none;margin-bottom:6px">
<div style="font-size:13px;margin-bottom:4px">Linked: <span id="phoneShow" style="font-family:ui-monospace,monospace">—</span></div>
<button class="secondary" id="unlinkPhoneBtn" style="font:inherit;font-size:12px;background:var(--panel-2);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:6px 12px;cursor:pointer">Unlink</button>
</div>
<div id="phoneNone">
<input id="phoneInput" placeholder="+15551234567" 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="linkPhoneBtn" style="background:var(--sky);color:var(--sky-ink);border:0;font:inherit;font-weight:600;padding:7px 12px;border-radius:8px;cursor:pointer;width:100%">Link phone</button>
</div>
<div class="err" id="phoneErr" style="color:var(--err);font-size:11.5px;min-height:1em;margin-top:4px"></div>
<div style="color:var(--dim);font-size:11px;margin-bottom:14px">
We hash the number locally and publish a Nostr claim for the hash. Anyone who <i>already knows</i> your number can find you — same tradeoff as number-based messengers. Unlink any time.
</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>
@ -295,7 +309,7 @@
<div class="modal-bg" id="addModal">
<div class="modal">
<h3>Start a new conversation</h3>
<input id="addName" placeholder="hermes:alice · alice.bch · alice@hermes.tel · npub1…" autocomplete="off" spellcheck="false">
<input id="addName" placeholder="hermes:alice · alice.bch · +15551234567 · npub1…" autocomplete="off" spellcheck="false">
<div class="err" id="addErr"></div>
<div class="row">
<button class="secondary" id="addCancel">Cancel</button>
@ -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();

View file

@ -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/<hash>`, 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;

147
main.js
View file

@ -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.