Startpage layout reverted to the pre-0.3.131 shape. 0.3.131's full-page-
height sidebar was a mistake — slots in a short strip under a hero at
the top, cards and slots sharing one row of panels at the same height,
is what matches the design. .page/.main-col/.sidebar structure dropped,
hero back as a body sibling of .homepage, slots-list back to repeat(3,
72px) with minSlots=9. Two of the user's own tweaks baked into the
defaults: .section.chromeless on the left column so slot icons float
on the page background instead of sitting in a boxed panel, and
defaultState().iconSize = "large" (60 px tiles).
Game.X in the SM cards (replaces SilentMode.X in its slot, stub
description + a built-in gamepad brand mark until game.x goes live).
SilentMode.X moved to the quick-links strip as the 5th slot. moveItem
now strips the pinned-slot anchor when a q item moves to sm — a link
dragged onto the cards used to flip back on the next render because
its slot number was still set.
Nostr NIP-07 signer grows per-site identities (0.1.0 → 0.1.1). First
approval modal picks between the Hermes identity (one npub for DMs
and public notes, the one published as the np record on your BNS
name) and a site-specific HKDF-derived npub (unique per origin, so
Nostr clients can't cross-correlate your activity). The chooser shows
both npubs so the user sees what each button reveals. Grants without
a stored profileId (migrated from 0.3.131) default to the Hermes
identity so existing approvals keep behaving the same.
lib/hermes.js:nostrKeyFromRootForOrigin HKDFs with info
silentmode/nostr-origin/v1/<origin> over the existing 32-byte
messengerRoot. main.js _nostrAPI methods take {profileId, origin};
signer cache per profile.
Silent Mode Mail retired as an extension — now a Sidebar quicklink
(id: silentmail). Its 0.1.0 panel iframed mail.silentmode.st, which
sends X-Frame-Options: DENY; the browser rendered the block page and
STILL fired the iframe's load event, so the 5 s "did it embed?"
watchdog saw it as loaded and the panel stayed black. The whole
extension was just a landing page with an Open Mail button — exactly
what a sidebar quicklink does, without taking a panel slot or an
Extensions row. migrateAegisRename auto-retires any existing
extensions/mail/ to addons-backups/mail-migrated-<stamp>/ on first
boot of this Theseus; "mail" added to the first-party reserved-ids
list so a community add-on can't claim the id.
Claude connector 0.2.1 → 0.2.2: the bearer token no longer rotates on
every server stop. The old stop() generated a fresh token every time
Theseus quit, the panel toggled off, or a 0.3.X install replaced the
app — the Connect button wrote the new token to ~/.claude.json but
any already-running Claude Code or Claude Desktop process held the
previous token in memory and only re-reads config on launch, so /mcp
reconnect kept sending the stale bearer and the server rejected it
forever. Infinite loop: install Theseus upgrade, click Connect, MCP
still 401, close Claude, open Claude, install another upgrade a day
later, repeat. The explicit Revoke token button still rotates for
users who want fresh keys; everything else is persistent.
Sidebar panel apps blank to about:blank after 2 min of being closed to
free their render tree (iframes, media, listeners). Fast reopen within
the window still reuses the loaded page.
Light theme palette flip under body.light — the full set of
--ink / --mut / --card / --line tokens flip plus hero + card
translucent panels switch to light-glass, so hero titles, SM card
text, slot labels and the ".bch" tld read correctly on the light
background preset instead of as pale ghosts.
441 lines
20 KiB
JavaScript
441 lines
20 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) };
|
|
}
|
|
|
|
/** Per-origin Nostr identity: HKDF(messengerRoot, info = "nostr-origin/v1/<origin>")
|
|
* gives each site its own secp256k1 key, so a page at primal.net and one at
|
|
* nostrudel.ninja see different npubs and cannot correlate the user across
|
|
* Nostr clients. The derivation is deterministic from messengerRoot, so the
|
|
* same mnemonic rebuilds every per-origin key. Compromising messengerRoot
|
|
* (= compromising the Hermes identity) exposes every per-origin key too —
|
|
* domain separation is only against the pages, not against seed theft. */
|
|
export async function nostrKeyFromRootForOrigin(root, origin) {
|
|
const rootBytes = typeof root === "string"
|
|
? Uint8Array.from(Buffer.from(root, "hex"))
|
|
: root;
|
|
if (!(rootBytes instanceof Uint8Array) || rootBytes.length !== 32) {
|
|
throw new Error("nostrKeyFromRootForOrigin: expected 32-byte Uint8Array or 64-char hex");
|
|
}
|
|
const originStr = String(origin || "").trim().toLowerCase();
|
|
if (!originStr) throw new Error("nostrKeyFromRootForOrigin: origin is required");
|
|
const key = await wc.subtle.importKey("raw", rootBytes, "HKDF", false, ["deriveBits"]);
|
|
const info = enc.encode(`silentmode/nostr-origin/v1/${originStr}`);
|
|
const sk = new Uint8Array(await wc.subtle.deriveBits(
|
|
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256));
|
|
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, attestation } = {}) {
|
|
if (!sk) throw new Error("sk required");
|
|
const hash = await hashPhone(phone);
|
|
const tags = [["d", PHONE_DTAG_PREFIX + hash]];
|
|
// Embed a Hermes-verify attestation when the caller has one. Format:
|
|
// ["att", "tg:<base64url payload>:<base64url sig>"] — resolvers verify
|
|
// against the pinned Ed25519 pubkey. See VPS/SilentMode/hermes-verify.
|
|
if (attestation && attestation.payload && attestation.sig) {
|
|
tags.push(["att", "tg:" + attestation.payload + ":" + attestation.sig]);
|
|
}
|
|
return finalizeEvent({
|
|
kind: 30078,
|
|
content: "{}",
|
|
tags,
|
|
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 };
|
|
}
|