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:
parent
b5b3e5c1b2
commit
5e27a1b53d
4 changed files with 281 additions and 1 deletions
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
147
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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue