Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
|
|
|
// 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";
|
|
|
|
|
|
2026-10-22 02:02:25 +03:00
|
|
|
// --- 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;
|
|
|
|
|
}
|
|
|
|
|
|
Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
|
|
|
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) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 00:38:52 +02:00
|
|
|
/** 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) };
|
|
|
|
|
}
|
|
|
|
|
|
Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
|
|
|
// ---- 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;
|
|
|
|
|
|
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
|
|
|
// ---- 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}`;
|
|
|
|
|
}
|
|
|
|
|
|
Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
|
|
|
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 }),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
hermes: Nostr kind:0 profiles — display name, picture, about
Peers' chat-list labels now cascade: contact nickname → kind:0 display name →
BNS handle → pk preview. Avatars render as a URL picture when kind:0 carries
one, with initials as fallback. Profiles are fetched on demand (background,
non-blocking) when a new peer pk shows up in the inbox or contact list, and
cached on disk so a cold start renders instantly.
- lib/hermes.js: buildProfileEvent({sk, displayName, picture, about}) emits
a signed kind:0 with `name` + `display_name` set together (clients vary
on which they prefer). parseProfileContent(content) handles both.
- main.js: profile cache at <userData>/hermes/profiles.json; auto-fetch
from HERMES_DEFAULT_RELAYS with a 4 s per-relay cap. hermesPublishEvent
centralises relay publish (used by profile save; send() retains its
inlined copy for the DRY-up later). hermesRecord() kicks off a background
profile fetch the first time a peer is seen; result fires on the
hermes-profile bus event.
- main.js: _hermesAPI.updateProfile / getProfile. Addon proxies both.
- panel.html: Settings → Profile editor (display name, picture URL, about);
publishes on save, pre-fills from the cache. Picture validated as http(s).
labelFor() + avatarFor() centralise the display cascade. Profile state
lives in state.profiles.
2026-10-21 17:18:07 +03:00
|
|
|
// ---- 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; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 17:21:16 +03:00
|
|
|
// ---- 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. */
|
2026-10-22 03:46:45 +03:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-21 17:21:16 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
Hermes: wire NIP-17 messaging into Theseus, provision chipnet hermes.bch
- Hermes/proof/: standalone keystone — BIP-39→Nostr, NIP-17 wrap/unwrap,
name-addressed send/receive verified end-to-end via public relay
- Argus/src/lib/hermes-derive.js: HKDF(silentmode/messenger/0) using libauth
so the registrar can compute np without pulling nostr-tools into Argus
- Argus/src/register-hermes-chipnet.mjs: idempotent REG/UPD script; publishes
np (Nostr pubkey) and nr (relay) records for a chipnet name
- TheseusNavigator/lib/hermes.js: same derivation + wrap/unwrap + record
parsing, canonical for Theseus; guarded by lib/package.json type:module
- TheseusNavigator/messages.html + messages-preload.js: Messages panel UI
(identity from mnemonic, live inbox, compose by .bch name), .bch suffix
stripped from displayed names
- TheseusNavigator/main.js: HERMES_MOD + loadHermesLib next to VAULT_MOD;
ipc handlers (hermes-status/init/close/inbox/send/open), per-relay
subscription with auto-reconnect, status pushed on WS open/close,
reverse-resolve pk -> .bch name via cached BNS index, Ctrl+Shift+M shortcut
via web-contents-created (works from any tab)
- Chipnet hermes.bch registered with np=f2c92519...67f4 nr=wss://nos.lol
(txid 7fc6de4544c13b782f163fdb892ea6749785886d05a336282fa659d722c7e92b);
end-to-end verified in Theseus
2026-08-18 20:14:43 +02:00
|
|
|
// ---- 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 };
|
|
|
|
|
}
|