theseus/lib/hermes.js
Local Dev 4133153f32 hermes: three-form handle parser, hermes: scheme display, save-forever signin
Panel + standalone window now accept all three handle forms as synonyms, and
the UI defaults to the hermes:alice scheme form. Signin is persistent across
restarts via safeStorage-sealed identity; the ⋯ menu adds Sign out.

- lib/hermes.js: parseHandle(input) → {kind, value} canonicalises any of
  hermes:alice, alice.erm, alice.bch, alice@parent.tld, npub1…, or raw hex.
  renderHandle(name, mode) formats a .erm name as hermes:alice (default),
  alice.erm (dotted), or alice@hermes.tel (email). Non-.erm names always
  render dotted so a .bch handle can't masquerade as a sub-identity.
- main.js: hermesResolveTo() unifies contacts-add + send + their panel-API
  twins behind one path. Sub-identity (alice@parent.tld) resolution returns
  a crisp "not available yet" until the mainnet mint of the parent name's
  signed-records manifest lands — the parser accepts the form today so UI
  copy and placeholders can show it.
- main.js: safeStorage-sealed identity at <userData>/hermes-identity/,
  auto-restored on app ready. New _hermesAPI.signOut wipes it. init() takes
  { remember: false } for a one-shot session (default: persist). A
  hermes-restored event fires on auto-restore so the panel skips the
  signin screen.
- panel.html: renderHandle() mirror client-side; ⋯ menu with "Signed in as
  hermes:alice" header + Sign out; placeholders show all three forms; the
  signin hint tells users they stay signed in.

On-chain `.erm` and `hermes.tel` mints are deliberately deferred to mainnet
per user direction — the parser and display paths are tld-agnostic, so a
mainnet switch is one constant change in lib/hermes.js (HERMES_TLD /
HERMES_EMAIL_PARENT) plus the panel's mirror of them.
2026-10-21 12:32:59 +03:00

262 lines
11 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";
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 }),
};
}
// ---- 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 };
}