theseus/lib/hermes.js
Local Dev bbe5d3d298 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.
2026-10-22 03:46:45 +03:00

410 lines
18 KiB
JavaScript

// Hermes: name-addressed NIP-17 messaging over Nostr.
//
// This module lives inside Theseus because it depends on `nostr-tools` (a
// non-trivial dep the shared library layer, Argus, shouldn't carry). Loaded
// from main.js via dynamic import — same pattern as password-vault.js.
//
// Layers, sender → wire (NIP-17 + NIP-59):
// 1. rumor = unsigned kind:14 chat event
// 2. seal = kind:13 signed by sender (rumor encrypted to recipient)
// 3. wrap = kind:1059 signed by ephemeral (seal encrypted to recipient)
//
// Only the wrap goes on the wire; the relay sees an ephemeral pubkey, an
// opaque ciphertext, and a #p tag — never the sender or the plaintext.
import { webcrypto as wc } from "node:crypto";
import {
finalizeEvent, generateSecretKey, getPublicKey, getEventHash,
verifyEvent, nip19, nip44,
} from "nostr-tools";
// --- scryptSync maxmem monkey-patch ----------------------------------------
// node:crypto defaults scryptSync's maxmem option to 32 MiB. The host-side
// mnemonic wrap uses N=2^15 r=8 p=1 which needs ~32 MiB exactly — Node throws
// MEMORY_LIMIT_EXCEEDED on the boundary. Lift the ceiling here so the Create
// account button stops erroring. Lives in this file (loaded dynamically from
// resources/lib/hermes.mjs, outside app.asar) so an installed Theseus can be
// hot-patched by replacing this file alone — a main.js fix would be stuck
// inside the asar until a new release ships.
import { createRequire as _createRequire } from "node:module";
const _requireFromHermes = _createRequire(import.meta.url);
const _nodeCryptoShared = _requireFromHermes("node:crypto");
const _origScryptSync = _nodeCryptoShared.scryptSync;
if (!_nodeCryptoShared.__hermesScryptPatched) {
_nodeCryptoShared.scryptSync = function (password, salt, keylen, options) {
const opts = Object.assign({ maxmem: 128 * 1024 * 1024 }, options || {});
return _origScryptSync.call(this, password, salt, keylen, opts);
};
_nodeCryptoShared.__hermesScryptPatched = true;
}
const enc = new TextEncoder();
// ---- key derivation --------------------------------------------------------
// Same shape password-vault uses: BIP-39 → 64-byte seed → 32-byte purpose root
// via HKDF-SHA256 with `info="silentmode/<purpose>"`. Different purpose string,
// so passwords/0 and messenger/0 are cryptographically disjoint — one leak
// never yields the other.
/** BIP-39 mnemonic → 64-byte seed (PBKDF2-SHA512, 2048 iters, standard). */
export async function bip39ToSeed(mnemonic, passphrase = "") {
const km = await wc.subtle.importKey("raw",
enc.encode(String(mnemonic).normalize("NFKD").trim()),
"PBKDF2", false, ["deriveBits"]);
const salt = enc.encode(("mnemonic" + String(passphrase || "")).normalize("NFKD"));
return new Uint8Array(await wc.subtle.deriveBits(
{ name: "PBKDF2", salt, iterations: 2048, hash: "SHA-512" }, km, 512));
}
/** Seed → 32-byte purpose root via HKDF. Purpose string is public — the
* security comes from the seed, not the info parameter. */
export async function seedToPurposeRoot(seedBytes, purpose) {
const key = await wc.subtle.importKey("raw", seedBytes, "HKDF", false, ["deriveBits"]);
const info = enc.encode(`silentmode/${purpose}`);
return new Uint8Array(await wc.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256));
}
/** BIP-39 mnemonic → { sk, pkHex, npub } — the Nostr identity for messaging.
* Path is "messenger/0" — reserved for Hermes in
* TheseusNavigator/ROADMAP-identity-wallet.md. */
export async function nostrKeyFromMnemonic(mnemonic, passphrase = "") {
const seed = await bip39ToSeed(mnemonic, passphrase);
const sk = await seedToPurposeRoot(seed, "messenger/0");
const pkHex = getPublicKey(sk);
return { sk, pkHex, npub: nip19.npubEncode(pkHex) };
}
/** Same identity but from an already-derived messenger/0 root (32 bytes) —
* used when the password vault already carries the root and re-entering the
* mnemonic would be redundant. Input is Uint8Array(32) or 64-char hex. */
export function nostrKeyFromRoot(root) {
const sk = typeof root === "string"
? Uint8Array.from(Buffer.from(root, "hex"))
: root;
if (!(sk instanceof Uint8Array) || sk.length !== 32) {
throw new Error("nostrKeyFromRoot: expected 32-byte Uint8Array or 64-char hex");
}
const pkHex = getPublicKey(sk);
return { sk, pkHex, npub: nip19.npubEncode(pkHex) };
}
// ---- record parsers --------------------------------------------------------
// The BNS resolver is transparent: whatever the name owner published in `r`
// lands as entry.records.<key>. Here we validate and normalise the two Hermes
// record types.
//
// np — Nostr pubkey, hex (64) or npub1…
// nr — comma/space-separated list of ws(s):// relay URLs
const HEX_PUBKEY_RE = /^[0-9a-f]{64}$/i;
// ---- handle forms ----------------------------------------------------------
// Hermes accepts three equivalent handle forms, all resolving to the same
// BNS registration under the hood:
//
// hermes:alice — URI scheme, hides the TLD (default display)
// alice.erm — dotted BNS form
// alice@hermes.tel — email form; `hermes.tel` is a BNS name whose
// signed-records manifest lists sub-identities
//
// `.erm` and `hermes.tel` are the planned on-chain names; both mint on
// mainnet, pending user action. Until then, parseHandle still accepts all
// three forms — resolution against the real chain just returns "not found"
// for unminted TLDs/parents. Existing `.bch` names keep working untouched.
export const HERMES_SCHEME = "hermes";
export const HERMES_TLD = "erm";
export const HERMES_EMAIL_PARENT = "hermes.tel";
const HANDLE_LOCAL_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
const DOTTED_NAME_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/;
/**
* Normalize any accepted handle form to a canonical lookup descriptor:
* { kind: "hex", value: "<64 hex>" }
* { kind: "npub", value: "npub1…" }
* { kind: "name", value: "alice.erm" } — BNS name lookup
* { kind: "sub", value: { local: "alice", parent: "hermes.tel" } }
* — signed-records
* sub-identity lookup
*/
export function parseHandle(input) {
const s = String(input || "").trim();
if (!s) throw new Error("empty handle");
if (/^[0-9a-f]{64}$/i.test(s)) return { kind: "hex", value: s.toLowerCase() };
if (s.toLowerCase().startsWith("npub1")) return { kind: "npub", value: s };
const lower = s.toLowerCase();
// hermes:alice — expand to the default TLD. `.erm` is the planned name,
// but any TLD switch is a one-line change to HERMES_TLD above.
const schemePrefix = HERMES_SCHEME + ":";
if (lower.startsWith(schemePrefix)) {
const local = lower.slice(schemePrefix.length).trim();
if (!HANDLE_LOCAL_RE.test(local)) throw new Error(`bad hermes: handle: ${s.slice(0, 48)}`);
return { kind: "name", value: `${local}.${HERMES_TLD}` };
}
// alice@parent.tld — email form, treated as a sub-identity under parent.
if (lower.includes("@")) {
const at = lower.indexOf("@");
const local = lower.slice(0, at);
const parent = lower.slice(at + 1);
if (!HANDLE_LOCAL_RE.test(local) || !DOTTED_NAME_RE.test(parent)) {
throw new Error(`bad email handle: ${s.slice(0, 48)}`);
}
return { kind: "sub", value: { local, parent } };
}
// alice.erm, alice.bch, alice.tel — dotted BNS name. Must have at least
// one dot to disambiguate from "bare" sub-identity requests (which the
// client enriches with the default parent before calling in).
if (DOTTED_NAME_RE.test(lower)) {
return { kind: "name", value: lower };
}
throw new Error(`unrecognised handle: ${s.slice(0, 48)}`);
}
/**
* Render a canonical descriptor (or a BNS name) in the user's preferred form.
* Modes:
* "scheme" (default) → hermes:alice (only when the name is under HERMES_TLD)
* "dotted" → alice.erm
* "email" → alice@hermes.tel (only for HERMES_TLD names)
* Non-hermes names (alice.bch, bob.sc) always render dotted — the scheme
* and email forms are reserved for the Hermes TLD so a .bch name can't
* accidentally look like a sub-identity.
*/
export function renderHandle(name, mode = "scheme") {
const n = String(name || "").toLowerCase();
if (!n || !n.includes(".")) return n;
const suffix = "." + HERMES_TLD;
if (!n.endsWith(suffix)) return n;
const local = n.slice(0, -suffix.length);
if (mode === "dotted") return n;
if (mode === "email") return `${local}@${HERMES_EMAIL_PARENT}`;
return `${HERMES_SCHEME}:${local}`;
}
export function parseNpRecord(raw) {
if (typeof raw !== "string" || !raw.trim()) throw new Error("np: empty or missing");
const v = raw.trim();
if (HEX_PUBKEY_RE.test(v)) return v.toLowerCase();
if (v.startsWith("npub1")) {
const d = nip19.decode(v);
if (d.type !== "npub" || typeof d.data !== "string" || !HEX_PUBKEY_RE.test(d.data)) {
throw new Error("np: npub decoded to unexpected shape");
}
return d.data.toLowerCase();
}
throw new Error(`np: not hex(64) and not npub1…: ${v.slice(0, 24)}…`);
}
export function parseNrRecord(raw, { defaults = [] } = {}) {
if (typeof raw !== "string" || !raw.trim()) return defaults.slice();
const seen = new Set(); const out = [];
for (const tok of raw.split(/[,\s]+/)) {
const url = tok.trim();
if (!/^wss?:\/\//i.test(url)) continue;
if (seen.has(url)) continue;
seen.add(url); out.push(url);
}
return out.length ? out : defaults.slice();
}
/** Extract Hermes recipient info from a resolver Entry. */
export function parseHermesRecords(entry, { defaultRelays = [] } = {}) {
if (!entry || !entry.records) throw new Error("no on-chain entry for name");
return {
npPk: parseNpRecord(entry.records.np),
relays: parseNrRecord(entry.records.nr, { defaults: defaultRelays }),
};
}
// ---- Kind 0 profile (display metadata) -------------------------------------
// NIP-01 defines kind:0 as "set_metadata" — a user-authored event whose
// content is a JSON blob with human-readable fields. Standard fields we
// touch: `name`, `display_name`, `picture`, `about`. Nostr clients fetch
// kind:0 by author pubkey, newest wins; the sender's `name` renders in
// chat lists next to messages.
//
// Hermes keeps the unique handle (BNS registration) and the display metadata
// (kind:0) orthogonal — the handle is for addressing, the metadata is for
// how you WANT to appear. Users can leave kind:0 empty and still receive
// messages; conversely a kind:0 with just a nickname is enough to make the
// chat list look like a normal chat list without going on-chain.
export function buildProfileEvent({ sk, displayName, picture, about, extra } = {}) {
if (!sk) throw new Error("sk required");
const content = { ...(extra || {}) };
if (displayName) { content.display_name = String(displayName); content.name = String(displayName); }
if (picture) content.picture = String(picture);
if (about) content.about = String(about);
return finalizeEvent({
kind: 0,
content: JSON.stringify(content),
tags: [],
created_at: Math.floor(Date.now() / 1000),
}, sk);
}
export function parseProfileContent(content) {
if (!content || typeof content !== "string") return null;
try {
const o = JSON.parse(content);
if (!o || typeof o !== "object") return null;
return {
displayName: typeof o.display_name === "string" ? o.display_name :
typeof o.name === "string" ? o.name : null,
picture: typeof o.picture === "string" ? o.picture : null,
about: typeof o.about === "string" ? o.about : null,
};
} 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. */
// 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({
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;
const KIND_GIFT_WRAP = 1059;
// NIP-59: randomise timestamps up to 2 days in the past so relays can't
// correlate wrap-arrival with real send-time.
const TWO_DAYS = 2 * 24 * 60 * 60;
const jitteredNow = () =>
Math.floor(Date.now() / 1000) - Math.floor(Math.random() * TWO_DAYS);
const convKey = (sk, pkHex) => nip44.v2.utils.getConversationKey(sk, pkHex);
const encrypt = (sk, pkHex, plain) => nip44.v2.encrypt(plain, convKey(sk, pkHex));
const decrypt = (sk, pkHex, cipher) => nip44.v2.decrypt(cipher, convKey(sk, pkHex));
/** Wrap a plaintext chat message from senderSk to recipientPkHex. */
export function wrapChat({ senderSk, recipientPkHex, text }) {
const senderPkHex = getPublicKey(senderSk);
const rumor = {
kind: KIND_CHAT,
pubkey: senderPkHex,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", recipientPkHex]],
content: text,
};
rumor.id = getEventHash(rumor);
const seal = finalizeEvent({
kind: KIND_SEAL,
created_at: jitteredNow(),
tags: [],
content: encrypt(senderSk, recipientPkHex, JSON.stringify(rumor)),
}, senderSk);
const ephemeralSk = generateSecretKey();
return finalizeEvent({
kind: KIND_GIFT_WRAP,
created_at: jitteredNow(),
tags: [["p", recipientPkHex]],
content: encrypt(ephemeralSk, recipientPkHex, JSON.stringify(seal)),
}, ephemeralSk);
}
/** Unwrap a kind:1059 gift wrap for receiverSk.
* Returns { senderPkHex, text, createdAt } or throws on tamper. */
export function unwrapChat({ receiverSk, wrap }) {
if (wrap.kind !== KIND_GIFT_WRAP) throw new Error(`not a gift wrap (kind ${wrap.kind})`);
if (!verifyEvent(wrap)) throw new Error("gift wrap signature invalid");
const seal = JSON.parse(decrypt(receiverSk, wrap.pubkey, wrap.content));
if (seal.kind !== KIND_SEAL) throw new Error(`inner is not a seal (kind ${seal.kind})`);
if (!verifyEvent(seal)) throw new Error("seal signature invalid");
const rumor = JSON.parse(decrypt(receiverSk, seal.pubkey, seal.content));
if (rumor.kind !== KIND_CHAT) throw new Error(`inner is not chat (kind ${rumor.kind})`);
if (rumor.pubkey !== seal.pubkey) throw new Error("rumor.pubkey does not match seal.pubkey");
return { senderPkHex: rumor.pubkey, text: rumor.content, createdAt: rumor.created_at };
}