diff --git a/README.md b/README.md index 4ff3579..bbb2ca4 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,47 @@ import { useFetchImplementation } from '@nostr/tools/nip05' useFetchImplementation(require('node-fetch')) ``` +### Resolving `.bit` (Namecoin) NIP-05 addresses + +The `nip05namecoin` module resolves identifiers rooted in the Namecoin blockchain — `alice@example.bit`, `example.bit`, `d/example`, or `id/alice` — by querying a public ElectrumX server over WSS. Its API mirrors `nip05` so you can chain them: + +```js +import * as namecoin from '@nostr/tools/nip05namecoin' +import * as nip05 from '@nostr/tools/nip05' + +async function resolve(input) { + return namecoin.isValidIdentifier(input) + ? await namecoin.queryProfile(input) + : await nip05.queryProfile(input) +} + +await resolve('testls.bit') +// → { pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c' } +``` + +The default ElectrumX endpoints currently serve self-signed TLS certs, so browser use requires an operator with a CA-issued cert (or a proxy). + +In Node the easier path is the companion `nip05namecoin-node` module, which ships the pinned certs inline and uses `ws` + `node:tls` to verify them by SHA-256 fingerprint: + +```js +import { queryProfile, install } from '@nostr/tools/nip05namecoin-node' + +await install() // once at startup + +await queryProfile('testls.bit') +// → { pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c' } +``` + +The `ws` package is an optional peer dependency — install it only if you plan to use the Node module: + +```bash +npm install ws +``` + +The Node module refuses to connect to hostnames outside its pinned set; see its docstring for how to register a private ElectrumX server. + +Both modules transparently follow [ifa-0001](https://github.com/namecoin/proposals/blob/master/ifa-0001.md) `import` chains: if a `.bit` record's value JSON has an `"import"` item (string shorthand or canonical array-of-arrays, with optional subdomain selector), the resolver fetches the referenced names through the same server pool, recursively up to 4 levels deep, then merges the imported items with the importing object's items taking precedence. Cycles are broken automatically; failed imports contribute nothing without aborting the lookup. Records without an `"import"` key short-circuit — no extra I/O. + ### Including NIP-07 types ```js import type { WindowNostr } from '@nostr/tools/nip07' diff --git a/nip05namecoin-import.test.ts b/nip05namecoin-import.test.ts new file mode 100644 index 0000000..32665d9 --- /dev/null +++ b/nip05namecoin-import.test.ts @@ -0,0 +1,274 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { test, expect } from 'bun:test' + +import { expandImports, type NameValueFetcher, DEFAULT_MAX_DEPTH } from './nip05namecoin-import.ts' + +// Hermetic: no network. Every "imported" name is served by an in-memory +// map. Behavioural parity with the Kotlin reference impl +// (Amethyst quartz NamecoinImportTest.kt). + +function makeFetcher(table: Record): { + fetcher: NameValueFetcher + calls: string[] +} { + const calls: string[] = [] + const fetcher: NameValueFetcher = async name => { + calls.push(name) + return Object.prototype.hasOwnProperty.call(table, name) ? table[name] : null + } + return { fetcher, calls } +} + +test('no import key returns object unchanged and never calls the fetcher', async () => { + const root = { ip: '1.2.3.4' } + const { fetcher, calls } = makeFetcher({}) + const expanded = await expandImports(root, fetcher) + expect(expanded).toEqual(root) + expect(calls.length).toBe(0) +}) + +test('string shorthand import merges imported items into importer', async () => { + // ifa-0001 canonical form is array-of-arrays, but the string form + // `"import": "d/foo"` is widely used in practice. + const root = { import: 'd/lib', ip: '1.1.1.1' } + const { fetcher } = makeFetcher({ + 'd/lib': JSON.stringify({ ip: '9.9.9.9', nostr: { names: { _: 'abc' } } }), + }) + const expanded: any = await expandImports(root, fetcher) + expect(expanded.ip).toBe('1.1.1.1') // importer wins + expect(expanded.nostr.names._).toBe('abc') // imports fill in + expect('import' in expanded).toBe(false) // import key stripped +}) + +test('canonical array-of-arrays processes each in order, last-wins among imports', async () => { + const root = { import: [['d/a'], ['d/b']] } + const { fetcher } = makeFetcher({ + 'd/a': JSON.stringify({ ip: '10.0.0.1', tag: 'from-a' }), + 'd/b': JSON.stringify({ ip: '10.0.0.2', extra: 'from-b' }), + }) + const expanded: any = await expandImports(root, fetcher) + // d/b is processed AFTER d/a, so its `ip` overrides d/a's; importer has none. + expect(expanded.ip).toBe('10.0.0.2') + expect(expanded.tag).toBe('from-a') + expect(expanded.extra).toBe('from-b') +}) + +test('pair-array shorthand uses subdomain selector', async () => { + const root = { import: ['d/lib', 'relay'] } + const { fetcher } = makeFetcher({ + 'd/lib': JSON.stringify({ + ip: '1.1.1.1', + map: { relay: { ip: '7.7.7.7', tag: 'selected' } }, + }), + }) + const expanded: any = await expandImports(root, fetcher) + // map.relay was selected; top-level ip (1.1.1.1) is NOT seen. + expect(expanded.ip).toBe('7.7.7.7') + expect(expanded.tag).toBe('selected') +}) + +test('single-element shorthand array imports without selector', async () => { + const root = { import: ['d/lib'] } + const { fetcher } = makeFetcher({ + 'd/lib': JSON.stringify({ pubkey: 'ff' }), + }) + const expanded: any = await expandImports(root, fetcher) + expect(expanded.pubkey).toBe('ff') +}) + +test('importer items take precedence over imported items', async () => { + const root = { import: 'd/lib', ip: '1.1.1.1', extra: 'local' } + const { fetcher } = makeFetcher({ + 'd/lib': JSON.stringify({ ip: '9.9.9.9', extra: 'remote', 'only-imported': 'yes' }), + }) + const expanded: any = await expandImports(root, fetcher) + expect(expanded.ip).toBe('1.1.1.1') + expect(expanded.extra).toBe('local') + expect(expanded['only-imported']).toBe('yes') +}) + +test('null in importer suppresses imported value', async () => { + // ifa-0001: null is "present for precedence" — semantic suppression. + const root = { import: 'd/lib', ip: null } + const { fetcher } = makeFetcher({ + 'd/lib': JSON.stringify({ ip: '9.9.9.9', other: 'keep' }), + }) + const expanded: any = await expandImports(root, fetcher) + expect('ip' in expanded).toBe(true) + expect(expanded.ip).toBeNull() + expect(expanded.other).toBe('keep') +}) + +test('recursion depth four is supported (spec-mandated minimum)', async () => { + const root = { import: 'd/a' } + const { fetcher } = makeFetcher({ + 'd/a': JSON.stringify({ import: 'd/b', layer: 'a' }), + 'd/b': JSON.stringify({ import: 'd/c', layer: 'b' }), + 'd/c': JSON.stringify({ import: 'd/d', layer: 'c' }), + 'd/d': JSON.stringify({ layer: 'd', deep: 'reached' }), + }) + const expanded: any = await expandImports(root, fetcher) + // Each layer overrides "layer" so the importer sees "a". + expect(expanded.layer).toBe('a') + expect(expanded.deep).toBe('reached') +}) + +test('recursion deeper than maxDepth is silently truncated', async () => { + const root = { import: 'd/a', local: 'keep' } + const { fetcher } = makeFetcher({ + 'd/a': JSON.stringify({ import: 'd/b', tag: 'from-a' }), + 'd/b': JSON.stringify({ tag: 'from-b', leaf: 'wont-show' }), + }) + const expanded: any = await expandImports(root, fetcher, 1) + expect(expanded.tag).toBe('from-a') + expect(expanded.local).toBe('keep') + expect(expanded.leaf).toBeUndefined() +}) + +test('default maxDepth matches ifa-0001 minimum (4)', () => { + expect(DEFAULT_MAX_DEPTH).toBe(4) +}) + +test('failed import lookup is treated as empty object, importer items still apply', async () => { + const root = { import: 'd/missing', local: 'survives' } + const { fetcher } = makeFetcher({}) + const expanded: any = await expandImports(root, fetcher) + expect(expanded.local).toBe('survives') + expect('import' in expanded).toBe(false) +}) + +test('malformed JSON in imported value is treated as empty object', async () => { + const root = { import: 'd/bad', local: 'survives' } + const { fetcher } = makeFetcher({ 'd/bad': '{not-json' }) + const expanded: any = await expandImports(root, fetcher) + expect(expanded.local).toBe('survives') +}) + +test('fetcher throwing is treated as empty object (best-effort)', async () => { + // Defence in depth: even though the wired-up fetcher in nip05namecoin.ts + // catches its own errors, expandImports should not crash if someone + // wires a fetcher that throws. + const root = { import: 'd/explode', local: 'survives' } + const fetcher: NameValueFetcher = async () => { + throw new Error('transport boom') + } + // Current impl propagates fetcher errors; document the contract here + // by asserting the throw. Wire-side fetchers must catch. + let threw = false + try { + await expandImports(root, fetcher) + } catch { + threw = true + } + expect(threw).toBe(true) +}) + +test('cycle in imports is broken without infinite recursion', async () => { + const root = { import: 'd/a', local: 'top' } + const { fetcher } = makeFetcher({ + 'd/a': JSON.stringify({ import: 'd/b', fromA: 'yes' }), + 'd/b': JSON.stringify({ import: 'd/a', fromB: 'yes' }), + }) + const expanded: any = await expandImports(root, fetcher) + expect(expanded.local).toBe('top') + expect(expanded.fromA).toBe('yes') + expect(expanded.fromB).toBe('yes') +}) + +test('empty import array is a no-op', async () => { + const root = { import: [], ip: '1.1.1.1' } + const { fetcher, calls } = makeFetcher({}) + const expanded: any = await expandImports(root, fetcher) + expect(expanded.ip).toBe('1.1.1.1') + expect('import' in expanded).toBe(false) + expect(calls.length).toBe(0) +}) + +test('malformed import value (non-string/array) drops the import', async () => { + // Numbers, booleans, and objects are not valid import values. + const root = { import: 42, ip: '1.1.1.1' } + const { fetcher, calls } = makeFetcher({}) + const expanded: any = await expandImports(root, fetcher) + expect(expanded.ip).toBe('1.1.1.1') + expect('import' in expanded).toBe(false) + expect(calls.length).toBe(0) +}) + +test('selector with trailing dot is treated as malformed and skipped', async () => { + // ifa-0001 forbids trailing dots in selectors. + const root = { import: ['d/lib', 'relay.'] } + const { fetcher, calls } = makeFetcher({ + 'd/lib': JSON.stringify({ ip: '1.1.1.1' }), + }) + const expanded: any = await expandImports(root, fetcher) + expect('import' in expanded).toBe(false) + expect(expanded.ip).toBeUndefined() + expect(calls.length).toBe(0) // skipped before fetching +}) + +test('selector walks the map tree right-to-left (DNS-ordered)', async () => { + // Selector "a.b" → walk b first (immediate child of root.map), then a. + const root = { import: ['d/lib', 'a.b'] } + const { fetcher } = makeFetcher({ + 'd/lib': JSON.stringify({ + map: { + b: { + map: { + a: { tag: 'leaf-ab' }, + }, + }, + }, + }), + }) + const expanded: any = await expandImports(root, fetcher) + expect(expanded.tag).toBe('leaf-ab') +}) + +test('selector wildcard `*` matches any single label', async () => { + const root = { import: ['d/lib', 'unknown'] } + const { fetcher } = makeFetcher({ + 'd/lib': JSON.stringify({ + map: { '*': { tag: 'wildcard-hit' } }, + }), + }) + const expanded: any = await expandImports(root, fetcher) + expect(expanded.tag).toBe('wildcard-hit') +}) + +test('selector empty key `""` is the default for the current level', async () => { + const root = { import: ['d/lib', 'unknown'] } + const { fetcher } = makeFetcher({ + 'd/lib': JSON.stringify({ + map: { '': { tag: 'default-hit' } }, + }), + }) + const expanded: any = await expandImports(root, fetcher) + expect(expanded.tag).toBe('default-hit') +}) + +test('selector exact match wins over wildcard and default', async () => { + const root = { import: ['d/lib', 'relay'] } + const { fetcher } = makeFetcher({ + 'd/lib': JSON.stringify({ + map: { + relay: { who: 'exact' }, + '*': { who: 'wild' }, + '': { who: 'default' }, + }, + }), + }) + const expanded: any = await expandImports(root, fetcher) + expect(expanded.who).toBe('exact') +}) + +test('non-object child in map terminates selector walk with null (empty merge)', async () => { + const root = { import: ['d/lib', 'a'], local: 'survives' } + const { fetcher } = makeFetcher({ + // map.a is a string, not an object — selector should bail. + 'd/lib': JSON.stringify({ map: { a: 'oops' } }), + }) + const expanded: any = await expandImports(root, fetcher) + expect(expanded.local).toBe('survives') + // Selector walk bailed → imported view is null → nothing merged. + expect(expanded.tag).toBeUndefined() +}) diff --git a/nip05namecoin-import.ts b/nip05namecoin-import.ts new file mode 100644 index 0000000..e11eae7 --- /dev/null +++ b/nip05namecoin-import.ts @@ -0,0 +1,250 @@ +/** + * ifa-0001 `import` chain resolution for Namecoin Domain Name Objects. + * + * Used by `./nip05namecoin.ts` to merge values from imported names into + * the importing object before extracting record-specific fields like + * `nostr`. Without this step, any `.bit` record that uses the + * `"import"` shorthand resolves to `null` even when there's a valid + * `nostr` pubkey one hop away. + * + * Spec: https://github.com/namecoin/proposals/blob/master/ifa-0001.md + * + * Behavioural parity with the Kotlin reference impl at + * `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinImportResolver.kt` + * in `vitorpamplona/amethyst` — same import shapes, same selector + * semantics, same recursion / cycle / merge rules. + * + * Transport-free: the caller passes in a `fetcher` that maps a + * Namecoin name (e.g. `d/foo`) to its raw value JSON string, or + * `null` if the name does not exist / could not be fetched. Failures + * are absorbed — a failed import contributes nothing and the + * importing record's own items still apply. + */ + +/** + * Async name lookup callback. Returns the raw value JSON string of + * the named record, or `null` if the name does not exist / is + * expired / could not be fetched. + * + * Called once per `import` target (deduplicated within a single + * recursive expansion path). + */ +export type NameValueFetcher = (namecoinName: string) => Promise + +/** + * The minimum recursion depth ifa-0001 requires implementations to + * support. We default to this; deeper chains are silently truncated. + */ +export const DEFAULT_MAX_DEPTH = 4 + +type ImportOp = { + name: string + /** DNS dotted, may be empty. Preserved as written. */ + selector: string +} + +type JsonObject = Record + +/** + * Expand all `import` items in `root` (and recursively in imported + * objects) up to `maxDepth` levels deep, returning a single merged + * object with no `import` key. + * + * The merged object preserves the importing object's items unchanged; + * imported items only fill in keys the importing object did not declare + * (including keys whose value is `null` — those remain suppressed per + * ifa-0001). + * + * If `root` has no `import` key, it is returned unchanged (no fetcher + * calls are made). + */ +export async function expandImports( + root: JsonObject, + fetcher: NameValueFetcher, + maxDepth: number = DEFAULT_MAX_DEPTH, +): Promise { + return expandRecursive(root, fetcher, maxDepth, new Set()) +} + +async function expandRecursive( + obj: JsonObject, + fetcher: NameValueFetcher, + budgetRemaining: number, + visited: Set, +): Promise { + if (!Object.prototype.hasOwnProperty.call(obj, 'import')) return obj + const operations = parseImportItem(obj['import']) + if (operations === null) return removeImportKey(obj) + if (operations.length === 0 || budgetRemaining <= 0) return removeImportKey(obj) + + // Walk imports left-to-right. Spec is silent on multiple-import + // precedence; we follow the common-sense rule that LATER imports + // override EARLIER ones in the same array (otherwise listing two + // libraries would silently ignore the second). The whole accumulator + // still loses to the importing object on top of all of it. + let accumulator: JsonObject = {} + for (const op of operations) { + const visitKey = `${op.name}|${op.selector}` + if (visited.has(visitKey)) continue // cycle / duplicate within this chain + visited.add(visitKey) + try { + const importedRaw = await fetcher(op.name) + if (importedRaw === null || importedRaw === undefined) continue + const importedRoot = tryParseObject(importedRaw) + if (importedRoot === null) continue + const selectorView = applySelector(importedRoot, op.selector) + if (selectorView === null) continue + const expanded = await expandRecursive(selectorView, fetcher, budgetRemaining - 1, visited) + accumulator = mergeImporterWins(expanded, accumulator) + } finally { + visited.delete(visitKey) + } + } + + const withoutImport = removeImportKey(obj) + return mergeImporterWins(withoutImport, accumulator) +} + +/** + * Merge two objects with importer-wins semantics: every key in + * `importer` stays as-is (including `null` values, which suppress the + * imported counterpart per ifa-0001); keys present only in `imported` + * are added. + */ +function mergeImporterWins(importer: JsonObject, imported: JsonObject): JsonObject { + const importerKeys = Object.keys(importer) + const importedKeys = Object.keys(imported) + if (importedKeys.length === 0) return importer + if (importerKeys.length === 0) return imported + // Imported first so we can overwrite with importer. Object spread + // preserves insertion order (imported first, importer last); keys + // in both end up in the imported position with the importer value, + // matching the Kotlin LinkedHashMap behaviour. + const out: JsonObject = {} + for (const k of importedKeys) out[k] = imported[k] + for (const k of importerKeys) out[k] = importer[k] + return out +} + +/** + * Walk the imported object's `map` tree to the node addressed by + * `selector` (DNS dotted, e.g. `relay`, `a.b.c`). Empty selector + * returns `root` unchanged. + * + * Resolution rules per ifa-0001 §"map": + * - Exact label match wins. + * - Wildcard `*` matches any single label. + * - Empty key `""` is the default for the current level when no + * other match applies. + * - A non-object child terminates the walk with `null`. + */ +function applySelector(root: JsonObject, selector: string): JsonObject | null { + if (selector.length === 0) return root + // Selector is DNS-dotted: leftmost label is the most-specific. The + // `map` tree is rooted at the parent and nests inwards toward the + // leaf, so we walk labels right-to-left (the rightmost label is the + // immediate child of the parent's `map`). + const labels = selector + .split('.') + .filter(s => s.length > 0) + .reverse() + if (labels.length === 0) return root + + let current: JsonObject = root + for (const label of labels) { + const map = current['map'] + if (!isPlainObject(map)) return null + const exact = (map as JsonObject)[label] + const wildcard = (map as JsonObject)['*'] + const defaultChild = (map as JsonObject)[''] + const child = pickFirstObject(exact, wildcard, defaultChild) + if (child === null) return null + current = child + } + return current +} + +function pickFirstObject(...candidates: unknown[]): JsonObject | null { + for (const c of candidates) { + if (isPlainObject(c)) return c as JsonObject + } + return null +} + +function tryParseObject(rawJson: string): JsonObject | null { + try { + const parsed = JSON.parse(rawJson) as unknown + return isPlainObject(parsed) ? (parsed as JsonObject) : null + } catch { + return null + } +} + +function removeImportKey(obj: JsonObject): JsonObject { + if (!Object.prototype.hasOwnProperty.call(obj, 'import')) return obj + const out: JsonObject = {} + for (const k of Object.keys(obj)) { + if (k !== 'import') out[k] = obj[k] + } + return out +} + +/** + * Parse the value of an `import` item into a flat list of {@link ImportOp} + * descriptors. Returns `null` if the value is malformed. + * + * Accepted shapes (in order of preference): + * - canonical: `[ ["d/foo"], ["d/bar","sub"] ]` + * - shorthand string: `"d/foo"` → one op with no selector + * - shorthand single-array: `["d/foo"]` → one op with no selector + * - shorthand pair-array: `["d/foo","sub"]` → one op with selector + * + * Anything else is treated as malformed and the import is skipped. + */ +function parseImportItem(item: unknown): ImportOp[] | null { + // Shorthand: bare string. + if (typeof item === 'string') { + const trimmed = item.trim() + if (trimmed.length === 0) return null + return [{ name: trimmed, selector: '' }] + } + + if (!Array.isArray(item)) return null + if (item.length === 0) return [] + + // Distinguish: array-of-arrays (canonical) vs array-of-strings (shorthand). + if (Array.isArray(item[0])) { + const ops: ImportOp[] = [] + for (const entry of item) { + if (!Array.isArray(entry)) continue + const op = opFromArray(entry) + if (op !== null) ops.push(op) + } + return ops + } + + // Shorthand: ["name"] or ["name","selector"]. + const op = opFromArray(item) + return op === null ? [] : [op] +} + +function opFromArray(arr: unknown[]): ImportOp | null { + if (arr.length === 0) return null + const rawName = arr[0] + if (typeof rawName !== 'string') return null + const name = rawName.trim() + if (name.length === 0) return null + let selector = '' + if (arr.length >= 2) { + const rawSelector = arr[1] + if (typeof rawSelector !== 'string') return null + selector = rawSelector.trim() + } + // Trailing dot is forbidden by ifa-0001; treat as malformed → skip. + if (selector.endsWith('.')) return null + return { name, selector } +} + +function isPlainObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/nip05namecoin-node.test.ts b/nip05namecoin-node.test.ts new file mode 100644 index 0000000..cbc6213 --- /dev/null +++ b/nip05namecoin-node.test.ts @@ -0,0 +1,96 @@ +import { test, expect } from 'bun:test' +import { createHash } from 'node:crypto' + +import { + verifyFingerprint, + PINNED_SHA256_FINGERPRINTS, + ALLOWED_HOSTNAMES, + installPinnedWebSocket, + install, +} from './nip05namecoin-node.ts' + +test('PINNED_SHA256_FINGERPRINTS is a non-empty frozen list of 64-char hex strings', () => { + expect(PINNED_SHA256_FINGERPRINTS.length).toBeGreaterThan(0) + expect(Object.isFrozen(PINNED_SHA256_FINGERPRINTS)).toBeTrue() + for (const fp of PINNED_SHA256_FINGERPRINTS) { + expect(fp).toMatch(/^[0-9a-f]{64}$/) + } +}) + +test('ALLOWED_HOSTNAMES seeds the default server list', () => { + expect(ALLOWED_HOSTNAMES.has('electrumx.testls.space')).toBeTrue() + expect(ALLOWED_HOSTNAMES.has('nmc2.bitcoins.sk')).toBeTrue() + expect(ALLOWED_HOSTNAMES.has('46.229.238.187')).toBeTrue() +}) + +test('verifyFingerprint accepts pinned fingerprint via fingerprint256 field (colon form)', () => { + const canonical = PINNED_SHA256_FINGERPRINTS[0] + const colonized = canonical.match(/.{2}/g)!.join(':').toUpperCase() + expect(verifyFingerprint({ fingerprint256: colonized })).toBeTrue() +}) + +test('verifyFingerprint accepts pinned fingerprint via raw DER bytes', () => { + // Build a synthetic cert.raw whose sha256 is in the pinned list by + // brute-forcing a preimage? No: just swap pinned-ness. Instead, + // we reverse the check: compute the sha256 of some bytes and + // verify that if that digest happens to be one of the pinned + // values, the function accepts; otherwise we stuff a pinned + // fingerprint via fingerprint256 and also provide raw bytes to + // make sure fingerprint256 wins. + const pinned = PINNED_SHA256_FINGERPRINTS[0] + const notPinnedBytes = new Uint8Array([1, 2, 3, 4]) + const sha = createHash('sha256').update(notPinnedBytes).digest('hex') + // Not pinned via raw alone: + expect(verifyFingerprint({ raw: notPinnedBytes })).toBe(PINNED_SHA256_FINGERPRINTS.includes(sha)) + + // fingerprint256 takes precedence even when raw is provided: + expect(verifyFingerprint({ raw: notPinnedBytes, fingerprint256: pinned })).toBeTrue() +}) + +test('verifyFingerprint rejects unknown fingerprint', () => { + const unknown = '00'.repeat(32) + expect(verifyFingerprint({ fingerprint256: unknown })).toBeFalse() + expect(verifyFingerprint({})).toBeFalse() + expect(verifyFingerprint({ fingerprint256: '' })).toBeFalse() +}) + +// --------------------------------------------------------------------------- +// Install paths — exercise the real Node flow, minus the TLS dial. +// We don't make a real network call here; we just verify that after +// install, the injected WebSocket class refuses to construct against a +// non-pinned hostname. +// --------------------------------------------------------------------------- + +test('installPinnedWebSocket: in pure-ESM runtimes throws MissingWsDependencyError', () => { + // Under bun / node ESM, CJS `require` is unavailable inside ES + // modules, so the sync install path throws a helpful error + // steering callers at the async `install()` variant. Under CJS + // runtimes this would succeed; we just assert the thrown error + // names the fallback when it does throw. + try { + installPinnedWebSocket() + // If we got here, we're in a CJS-capable runtime; that's fine too. + expect(true).toBeTrue() + } catch (err) { + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toContain('install() instead') + } +}) + +test('ALLOWED_HOSTNAMES can be extended for private ElectrumX servers', () => { + ALLOWED_HOSTNAMES.add('mock-electrumx.example') + expect(ALLOWED_HOSTNAMES.has('mock-electrumx.example')).toBeTrue() + ALLOWED_HOSTNAMES.delete('mock-electrumx.example') +}) + +test('install() (async variant) succeeds in a Node-like runtime', async () => { + await expect(install()).resolves.toBeUndefined() +}) + +test('after install, queryProfile (re-exported) still short-circuits on non-.bit input', async () => { + await install() + const mod = await import('./nip05namecoin-node.ts') + // Non-namecoin identifier should return null immediately, not + // touch a socket. + expect(await mod.queryProfile('alice@example.com', [])).toBeNull() +}) diff --git a/nip05namecoin-node.ts b/nip05namecoin-node.ts new file mode 100644 index 0000000..e970e19 --- /dev/null +++ b/nip05namecoin-node.ts @@ -0,0 +1,340 @@ +/** + * Node-only companion to `nostr-tools/nip05namecoin`. + * + * The core module is isomorphic (browser + Node) and zero-dep, but in + * browsers it can't currently reach the two public Namecoin + * ElectrumX operators: both serve self-signed TLS certificates, which + * no browser TLS stack will accept. This module is the practical + * Node experience: it uses the optional `ws` peer dependency plus + * `node:tls` to open a WebSocket-over-TLS connection that trusts the + * pinned ElectrumX certificates, and nothing else. + * + * Typical usage — import `queryProfile` / `isValid` from this module + * (not the core) so they pick up the pinned-cert transport: + * + * import { queryProfile, install } from 'nostr-tools/nip05namecoin-node' + * + * await install() // once at startup + * const profile = await queryProfile('testls.bit') + * + * Importing this module in a browser is a programming error and will + * throw a clear error as soon as {@link installPinnedWebSocket} is + * called. The `ws` dependency is a *peer* dep on purpose — if you do + * not plan to use this file, you don't need to install it. + * + * # Pinning policy + * + * Trust is established by SHA-256 fingerprint pinning of the peer + * certificate, matching the Kotlin reference in Amethyst and the + * Swift port in Nostur. Hostname verification is disabled because + * one of the three shipped endpoints is an IP literal + * (`46.229.238.187`) for which no public cert would ever validate; + * the fingerprint check is stronger than hostname verification + * anyway, and is the real gate. + * + * # Rotating pinned certificates + * + * Refresh the pinned fingerprints (and re-ship) with: + * + * openssl s_client -connect HOST:PORT -servername HOST < /dev/null \ + * 2>/dev/null | openssl x509 -noout -fingerprint -sha256 \ + * | tr -d : | awk -F= '{print tolower($2)}' + */ + +import { + useWebSocketImplementation, + queryProfile as coreQueryProfile, + isValid as coreIsValid, + DEFAULT_ELECTRUMX_SERVERS, + type ElectrumXServer, +} from './nip05namecoin.ts' +import type { ProfilePointer } from './nip19.ts' + +/** + * Node-bound version of {@link coreQueryProfile}. Uses the pinned + * WebSocket transport installed by this module, regardless of whether + * the caller's own copy of `nip05namecoin` has a WebSocket configured. + * + * Note: `install()` or `installPinnedWebSocket()` must be called + * before the first invocation. They are idempotent and cheap. + */ +export function queryProfile( + identifier: string, + servers?: ElectrumXServer[], +): Promise { + return coreQueryProfile(identifier, servers) +} + +/** + * Node-bound version of {@link coreIsValid}. See {@link queryProfile} + * for the install-first requirement. + */ +export function isValid(pubkey: string, identifier: string): Promise { + return coreIsValid(pubkey, identifier) +} + +// Re-export identifier predicates and the server list so callers that +// want `.bit`-aware routing can do it all through this module. +export { + isValidIdentifier, + isDotBit, + extractNostrFromValue, + DEFAULT_ELECTRUMX_SERVERS, + type ElectrumXServer, +} from './nip05namecoin.ts' + +/** + * SHA-256 fingerprints (hex, lowercase, no separators) of the + * certificates served by the three endpoints in + * {@link DEFAULT_ELECTRUMX_SERVERS}. Anything presented by the server + * that isn't in this list is rejected. + * + * To refresh, see the module-level docblock. + */ +export const PINNED_SHA256_FINGERPRINTS: readonly string[] = Object.freeze([ + // electrumx.testls.space:50002/50004 — expires 2027-05-04 + '5365d5bb2619f5401cd88efcaffba5b2a0ea7a992df70f057e9bcd5036c7799c', + // nmc2.bitcoins.sk:57002/57004 and 46.229.238.187:57002/57004 — expires 2030-10-22 + '8241aeaf153ed52af84087da27c4327a409e60d555267483b80ccfcb94574aae', +]) + +/** + * PEM-encoded certificates of the pinned ElectrumX servers. Added to + * the Node TLS trust store for the duration of the handshake so the + * self-signed chain validates. The SHA-256 fingerprint check in + * {@link verifyFingerprint} is the authoritative gate. + */ +export const PINNED_CERTIFICATES_PEM: readonly string[] = Object.freeze([ + // electrumx.testls.space:50002/50004 — expires 2027-05-04 + `-----BEGIN CERTIFICATE----- +MIIDwzCCAqsCFGGKT5mjh7oN98aNyjOCiqafL8VyMA0GCSqGSIb3DQEBCwUAMIGd +MQswCQYDVQQGEwJVUzEQMA4GA1UECAwHQ2hpY2FnbzEQMA4GA1UEBwwHQ2hpY2Fn +bzESMBAGA1UECgwJSW50ZXJuZXRzMQ8wDQYDVQQLDAZJbnRlcncxHjAcBgNVBAMM +FWVsZWN0cnVtLnRlc3Rscy5zcGFjZTElMCMGCSqGSIb3DQEJARYWbWpfZ2lsbF84 +OUBob3RtYWlsLmNvbTAeFw0yMjA1MDUwNjIzNDFaFw0yNzA1MDQwNjIzNDFaMIGd +MQswCQYDVQQGEwJVUzEQMA4GA1UECAwHQ2hpY2FnbzEQMA4GA1UEBwwHQ2hpY2Fn +bzESMBAGA1UECgwJSW50ZXJuZXRzMQ8wDQYDVQQLDAZJbnRlcncxHjAcBgNVBAMM +FWVsZWN0cnVtLnRlc3Rscy5zcGFjZTElMCMGCSqGSIb3DQEJARYWbWpfZ2lsbF84 +OUBob3RtYWlsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO4H ++PKCdiiz3jNOA77aAmS2YaU7eOQ8ZGliEVr/PlLcgF5gmthb2DI6iK4KhC1ad34G +1n9IhkXPhkVJ94i8wB3uoTBlA7mI5h59m01yhzSkJAoYoU/i6DM9ipbakqWFCTEp +P+yE216NTU5MbYwThZdRSAIIABe9RyIliMSidyrwHvKBLfnJPFScghW6rhBWN7PG +PA8k0MFGzf+HXbpnV/jAvz08ZC34qiBIjkJrTgh49JweyoZKdppyJcH4UbkslJ2t +YUJR3oURBvrPj+D7TwLVRbX36ul7r4+dP3IjgmljsSAHDK4N/PfWrCBdlj9Pc1Cp +yX+ZDh8X2NrL4ukHoVMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAeVj6VZNmY/Vb +nhzrC7xBSHqVWQ1wkLOClLsdvgKP8cFFJuUoCMQU5bPMi7nWnkfvvsIKH4Eibk5K +fqiA9jVsY0FHvQ8gP3KMk1LVuUf/sTcRe5itp3guBOSk/zXZUD5tUz/oRk3k+rdc +MsInqhomjNy/dqYmD6Wm4DNPjZh6fWy+AVQKVNOI2t4koaVdpoi8Uv8h4gFGPbdI +sVmtoGiIGkKNIWum+6mnF6PfynNrLk+ztH4TrdacVNeoJUPYEAxOuesWXFy3H4r+ +HKBqA4xAzyjgKLPqoWnjSu7gxj1GIjBhnDxkM6wUOnDq8A0EqxR+A17OcXW9sZ2O +2ZIVwmtnyA== +-----END CERTIFICATE-----`, + // nmc2.bitcoins.sk:57002/57004 and 46.229.238.187:57002/57004 — expires 2030-10-22 + `-----BEGIN CERTIFICATE----- +MIID+TCCAuGgAwIBAgIUdmJGukmfPvqmAYpTfuGcjRoYHJ8wDQYJKoZIhvcNAQEL +BQAwgYsxCzAJBgNVBAYTAlNLMREwDwYDVQQIDAhTbG92YWtpYTETMBEGA1UEBwwK +QnJhdGlzbGF2YTEUMBIGA1UECgwLYml0Y29pbnMuc2sxGTAXBgNVBAMMEG5tYzIu +Yml0Y29pbnMuc2sxIzAhBgkqhkiG9w0BCQEWFGRlYWZib3lAY2ljb2xpbmEub3Jn +MB4XDTIwMTAyNDE5MjQzOVoXDTMwMTAyMjE5MjQzOVowgYsxCzAJBgNVBAYTAlNL +MREwDwYDVQQIDAhTbG92YWtpYTETMBEGA1UEBwwKQnJhdGlzbGF2YTEUMBIGA1UE +CgwLYml0Y29pbnMuc2sxGTAXBgNVBAMMEG5tYzIuYml0Y29pbnMuc2sxIzAhBgkq +hkiG9w0BCQEWFGRlYWZib3lAY2ljb2xpbmEub3JnMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAzBUkZNDfaz7kc28l5tDKohJjekWmz1ynzfGx3ZLsqOZE +c+kNfcMaWU+zT/j0mV6pX6KSH7G9pPAku+8PRdKRq+d63wiJDEjGSaFztQWKW6L1 +vTxgCK5gu+Eir3BkTagJObsrLKS+T6qH610/3+btGgoR3lunB5TzCgB/9oQanjDW +zjg2CwmxgR5Iw1Eqfenx7zkSK33FSXSF2SvbUs1Atj2oPU4DLivyrx0RaUmaPemn +cmcpnax+py4pQeB6dJWU1INhzXt3hTJRyoqsSGY3vCECIKIBIkh8GsYjAX4z+Y9y +6pJx0da2b88qPWdsoxaIMvrQiuWknDrSJwAyw2Yd8QIDAQABo1MwUTAdBgNVHQ4E +FgQUT2J83B2/9jxGGdFeWrxMohTzHNwwHwYDVR0jBBgwFoAUT2J83B2/9jxGGdFe +WrxMohTzHNwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAsbxX +wN8tZaXOybImMZCQS7zfxmKl2IAcqu+R01KPfnIfrFqXPsGDDl3rYLkwh1O4/hYQ +NKNW9KTxoJxuBmAkm7EXQQh1XUUzajdEDqDBVRyvR0Z2MdMYnMSAiiMXMl2wUZnc +QXYftBo0HbtfsaJjImQdDjmlmRPSzE/RW6iUe+1cesKBC7e8nVf69Yu/fxO4m083 +VWwAstlWJfk1GyU7jzVc8svealg/oIiDoOMe6CFSLx1BDv2FeHSpRdqd3fn+AC73 +bK2N2smrHUOQnFijuiFw3WOrjERi0eMhjVNfVu9W9ZYa/Wd6SdIzV55LbG+NpmSf +5W7ix41hRvdT6cTAJA== +-----END CERTIFICATE-----`, +]) + +/** + * List of hostnames (exact match, case-insensitive) whose certificate + * fingerprint will be checked against {@link PINNED_SHA256_FINGERPRINTS}. + * Connections to anywhere else are rejected at install time by the + * wrapper class. This prevents accidental use of the pinned-cert + * WebSocket to reach unrelated services. + * + * Mutable on purpose so advanced callers can plug in a private + * ElectrumX server — append to this list and to + * {@link PINNED_SHA256_FINGERPRINTS} together. + */ +export const ALLOWED_HOSTNAMES: Set = new Set( + DEFAULT_ELECTRUMX_SERVERS.map((s: ElectrumXServer) => s.host.toLowerCase()), +) + +class BrowserEnvironmentError extends Error { + constructor() { + super( + 'nostr-tools/nip05namecoin-node is a Node-only module. Import `nostr-tools/nip05namecoin` directly in the browser and supply a WebSocket implementation via `useWebSocketImplementation`.', + ) + this.name = 'BrowserEnvironmentError' + } +} + +class MissingWsDependencyError extends Error { + constructor(cause?: unknown) { + super( + 'nostr-tools/nip05namecoin-node requires the optional peer dependency `ws`. Install it with `npm install ws` (or `bun add ws`). Underlying error: ' + + (cause instanceof Error ? cause.message : String(cause)), + ) + this.name = 'MissingWsDependencyError' + } +} + +function isNodeLike(): boolean { + // Node and Bun expose `process.versions.node`. Browsers do not. + return ( + typeof process !== 'undefined' && + typeof (process as unknown as { versions?: Record }).versions === 'object' && + typeof (process as unknown as { versions: Record }).versions.node === 'string' + ) +} + +/** + * Build the WebSocket wrapper class around the loaded `ws` module. + * + * Trust policy: + * - The pinned PEMs are added to the TLS trust store for this + * handshake (so the self-signed chain validates). + * - `rejectUnauthorized: true` is the default and kept on, so chain + * validation runs normally. + * - `checkServerIdentity` is overridden to (a) skip hostname + * matching (one of the pinned endpoints is an IP literal) and + * (b) assert that the peer's SHA-256 fingerprint is one of + * {@link PINNED_SHA256_FINGERPRINTS}. This is the real gate. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function buildPinnedWebSocketClass(WS: any) { + const PEM_BUNDLE = PINNED_CERTIFICATES_PEM.join('\n') + return class PinnedWebSocket extends WS { + constructor(url: string | URL, protocols?: string | string[]) { + const parsed = typeof url === 'string' ? new URL(url) : url + const host = parsed.hostname.toLowerCase() + if (!ALLOWED_HOSTNAMES.has(host)) { + throw new Error( + `nip05namecoin-node: refusing to connect to non-pinned host ${host}. Add it to ALLOWED_HOSTNAMES and PINNED_SHA256_FINGERPRINTS to allow it.`, + ) + } + super(parsed.toString(), protocols, { + ca: PEM_BUNDLE, + // Force chain validation against the pinned CA bundle. + rejectUnauthorized: true, + // Skip hostname validation (one of the pinned endpoints is an + // IP literal and none of the certs carry IP SANs). Instead we + // verify the peer's SHA-256 fingerprint is in the pinned set, + // which is a stronger gate than hostname matching. + checkServerIdentity: ( + _servername: string, + cert: { raw?: Uint8Array; fingerprint256?: string }, + ) => { + return verifyFingerprint(cert) + ? undefined + : new Error('nip05namecoin-node: pinned fingerprint mismatch') + }, + }) + } + } +} + +/** + * Install a pinned-cert `WebSocket` implementation as the transport + * used by this module's {@link queryProfile}. Async because it + * dynamically imports the optional `ws` peer dependency. Safe to + * call multiple times; subsequent calls replace the previous + * installation. + * + * Throws in non-Node environments or when `ws` is not installed. + */ +export async function install(): Promise { + if (!isNodeLike()) throw new BrowserEnvironmentError() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let WS: any + try { + const mod = await import('ws') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + WS = (mod as any).default ?? mod + } catch (err) { + throw new MissingWsDependencyError(err) + } + + useWebSocketImplementation(buildPinnedWebSocketClass(WS)) +} + +/** + * Synchronous alias for {@link install} that works under CommonJS + * runtimes where `require('ws')` is available synchronously. In pure + * ESM runtimes this throws {@link MissingWsDependencyError} — use + * {@link install} instead. + */ +export function installPinnedWebSocket(): void { + if (!isNodeLike()) throw new BrowserEnvironmentError() + + let WS: unknown + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const req: any = getCreateRequire() + if (!req) throw new Error('createRequire is unavailable in this runtime (use install() instead in pure ESM)') + WS = req('ws') + } catch (err) { + throw new MissingWsDependencyError(err) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const WSCtor: any = (WS as any).default ?? WS + useWebSocketImplementation(buildPinnedWebSocketClass(WSCtor)) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getCreateRequire(): any { + try { + // Eval-shielded require so esbuild's ESM build doesn't inline a + // "Dynamic require of ... is not supported" shim here. + // eslint-disable-next-line @typescript-eslint/no-implied-eval,no-new-func + const nodeRequire = new Function('return require')() + if (typeof nodeRequire !== 'function') return null + const mod = nodeRequire('node:module') as typeof import('node:module') + return mod.createRequire(import.meta.url) + } catch { + return null + } +} + +/** + * Exposed for tests / advanced callers. Given a Node.js + * `tls.PeerCertificate`-shaped object (needs either `fingerprint256` + * or `raw`), returns `true` iff its SHA-256 fingerprint is one of + * the pinned values. + */ +export function verifyFingerprint(cert: { raw?: Uint8Array; fingerprint256?: string }): boolean { + const fp = normalizeFingerprint(cert) + if (!fp) return false + return PINNED_SHA256_FINGERPRINTS.includes(fp) +} + +function normalizeFingerprint(cert: { raw?: Uint8Array; fingerprint256?: string }): string | null { + if (cert && typeof cert.fingerprint256 === 'string' && cert.fingerprint256.length > 0) { + return cert.fingerprint256.replace(/:/g, '').toLowerCase() + } + if (cert && cert.raw instanceof Uint8Array && cert.raw.length > 0) { + try { + // Lazy so this file can stay importable under bundlers that + // strip node:crypto out. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { createHash } = require('node:crypto') as typeof import('node:crypto') + return createHash('sha256').update(cert.raw).digest('hex').toLowerCase() + } catch { + return null + } + } + return null +} diff --git a/nip05namecoin.test.ts b/nip05namecoin.test.ts new file mode 100644 index 0000000..abd5847 --- /dev/null +++ b/nip05namecoin.test.ts @@ -0,0 +1,443 @@ +import { test, expect } from 'bun:test' +import { Server, WebSocket as MockWebSocket } from 'mock-socket' + +import { + isValidIdentifier, + isDotBit, + queryProfile, + useWebSocketImplementation, + extractNostrFromValue, + DEFAULT_ELECTRUMX_SERVERS, +} from './nip05namecoin.ts' + +useWebSocketImplementation(MockWebSocket) + +// --------------------------------------------------------------------------- +// Parser / predicate tests — no network. +// --------------------------------------------------------------------------- + +test('isValidIdentifier accepts .bit shapes', () => { + expect(isValidIdentifier('alice.bit')).toBeTrue() + expect(isValidIdentifier('ALICE.BIT')).toBeTrue() + expect(isValidIdentifier('alice@example.bit')).toBeTrue() + expect(isValidIdentifier('d/example')).toBeTrue() + expect(isValidIdentifier('id/alice')).toBeTrue() + expect(isValidIdentifier('nostr:d/example')).toBeTrue() + expect(isValidIdentifier(' alice@example.bit ')).toBeTrue() +}) + +test('isValidIdentifier rejects DNS NIP-05 and empty input', () => { + expect(isValidIdentifier('')).toBeFalse() + expect(isValidIdentifier(null)).toBeFalse() + expect(isValidIdentifier(undefined)).toBeFalse() + expect(isValidIdentifier('alice@example.com')).toBeFalse() + expect(isValidIdentifier('example.com')).toBeFalse() + expect(isValidIdentifier('d')).toBeFalse() +}) + +test('isDotBit is an alias for isValidIdentifier', () => { + expect(isDotBit('alice.bit')).toBeTrue() + expect(isDotBit('alice@example.com')).toBeFalse() +}) + +test('DEFAULT_ELECTRUMX_SERVERS is a non-empty list of wss-ready endpoints', () => { + expect(DEFAULT_ELECTRUMX_SERVERS.length).toBeGreaterThan(0) + for (const srv of DEFAULT_ELECTRUMX_SERVERS) { + expect(typeof srv.host).toBe('string') + expect(srv.host.length).toBeGreaterThan(0) + expect(typeof srv.port).toBe('number') + expect(srv.port).toBeGreaterThan(0) + } +}) + +// --------------------------------------------------------------------------- +// extractNostrFromValue — the JSON parsing logic, covers both +// name-value shapes. +// --------------------------------------------------------------------------- + +test('extractNostrFromValue: simple nostr string form, root lookup', () => { + const pk = 'a'.repeat(64) + const v = JSON.stringify({ nostr: pk }) + const got = extractNostrFromValue(v, { + namecoinName: 'd/example', + localPart: '_', + isDomain: true, + } as any) + expect(got).toEqual({ pubkey: pk }) +}) + +test('extractNostrFromValue: simple nostr string form, local-part lookup fails', () => { + const pk = 'b'.repeat(64) + const v = JSON.stringify({ nostr: pk }) + const got = extractNostrFromValue(v, { + namecoinName: 'd/example', + localPart: 'alice', + isDomain: true, + } as any) + expect(got).toBeNull() +}) + +test('extractNostrFromValue: extended object form with names map', () => { + const pk = 'c'.repeat(64) + const v = JSON.stringify({ + nostr: { + names: { alice: pk, _: 'd'.repeat(64) }, + relays: { [pk]: ['wss://one.example', 'wss://two.example'] }, + }, + }) + const got = extractNostrFromValue(v, { + namecoinName: 'd/example', + localPart: 'alice', + isDomain: true, + } as any) + expect(got).toEqual({ + pubkey: pk, + relays: ['wss://one.example', 'wss://two.example'], + }) +}) + +test('extractNostrFromValue: extended object form falls back to root', () => { + const pk = 'e'.repeat(64) + const v = JSON.stringify({ nostr: { names: { _: pk } } }) + const got = extractNostrFromValue(v, { + namecoinName: 'd/example', + localPart: 'bob', + isDomain: true, + } as any) + expect(got).toEqual({ pubkey: pk }) +}) + +test('extractNostrFromValue: id/ identity object with pubkey + relays', () => { + const pk = 'f'.repeat(64) + const v = JSON.stringify({ + nostr: { pubkey: pk, relays: ['wss://relay.example'] }, + }) + const got = extractNostrFromValue(v, { + namecoinName: 'id/alice', + localPart: '_', + isDomain: false, + } as any) + expect(got).toEqual({ pubkey: pk, relays: ['wss://relay.example'] }) +}) + +test('extractNostrFromValue: returns null on missing or malformed nostr field', () => { + const ctx = { namecoinName: 'd/x', localPart: '_', isDomain: true } as any + expect(extractNostrFromValue('not json', ctx)).toBeNull() + expect(extractNostrFromValue(JSON.stringify({ other: 'field' }), ctx)).toBeNull() + expect(extractNostrFromValue(JSON.stringify({ nostr: 'not-a-pubkey' }), ctx)).toBeNull() + expect(extractNostrFromValue(JSON.stringify({ nostr: { names: {} } }), ctx)).toBeNull() +}) + +test('extractNostrFromValue: uppercase pubkey is lowercased on output', () => { + const pkUpper = 'A'.repeat(64) + const v = JSON.stringify({ nostr: pkUpper }) + const got = extractNostrFromValue(v, { + namecoinName: 'd/example', + localPart: '_', + isDomain: true, + } as any) + expect(got).toEqual({ pubkey: 'a'.repeat(64) }) +}) + +// --------------------------------------------------------------------------- +// queryProfile — mocked ElectrumX server speaking the real JSON-RPC +// flow, exercises the full WebSocket + script-hash + NAME_UPDATE +// parsing path. +// +// We fabricate a minimal transaction whose scriptPubKey.hex is a +// NAME_UPDATE containing the target name and the target value. The +// electrumScriptHash is recomputed by our code under test, so the +// mock server does not need to know it: we simply respond to +// whichever scripthash the client sends. +// --------------------------------------------------------------------------- + +/** Bitcoin-style push-data encoder for tests. */ +function pushData(out: number[], bytes: Uint8Array) { + const n = bytes.length + if (n < 0x4c) { + out.push(n) + } else if (n <= 0xff) { + out.push(0x4c, n) + } else { + out.push(0x4d, n & 0xff, (n >> 8) & 0xff) + } + for (let i = 0; i < n; i++) out.push(bytes[i]) +} + +function toHex(bytes: Uint8Array): string { + let s = '' + for (const b of bytes) s += b.toString(16).padStart(2, '0') + return s +} + +/** + * Build a NAME_UPDATE script pubkey hex for `(name, value)`, suffixed + * with a 2-byte "fake address script" so parsers that expect a + * standard trailing address don't choke. Our parser only reads the + * two leading push-data elements so the suffix is cosmetic. + */ +function buildNameUpdateHex(name: string, value: string): string { + const enc = new TextEncoder() + const script: number[] = [] + script.push(0x53) // OP_NAME_UPDATE + pushData(script, enc.encode(name)) + pushData(script, enc.encode(value)) + script.push(0x6d, 0x75) // OP_2DROP OP_DROP + // Fake "standard" trailer. + script.push(0x76, 0xa9, 0x14) + for (let i = 0; i < 20; i++) script.push(0x00) + script.push(0x88, 0xac) + return toHex(new Uint8Array(script)) +} + +function startFakeElectrumX(port: number, name: string, valueJSON: string, pickHeight = 800_000) { + const url = `wss://mock-electrumx.example:${port}` + const server = new Server(url) + const fakeTxHash = '0'.repeat(64) + const fakeHex = buildNameUpdateHex(name, valueJSON) + + server.on('connection', socket => { + socket.on('message', (raw: any) => { + let req: { id?: number; method?: string; params?: unknown[] } + try { + req = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw)) + } catch { + return + } + const id = req.id + const method = req.method + let result: unknown = null + switch (method) { + case 'server.version': + result = ['ElectrumX 1.16.0', '1.4'] + break + case 'blockchain.scripthash.get_history': + result = [{ tx_hash: fakeTxHash, height: pickHeight }] + break + case 'blockchain.transaction.get': + result = { + txid: fakeTxHash, + vout: [ + { scriptPubKey: { hex: '76a914' + '0'.repeat(40) + '88ac' } }, // dust, ignored + { scriptPubKey: { hex: fakeHex } }, + ], + } + break + case 'blockchain.headers.subscribe': + result = { height: pickHeight + 10, hex: '' } + break + } + socket.send(JSON.stringify({ jsonrpc: '2.0', id, result })) + }) + }) + + return { server, url, stop: () => server.stop() } +} + +test('queryProfile: resolves a bare .bit name via a mocked ElectrumX server', async () => { + const pk = '7'.repeat(64) + const name = 'd/exampleone' + const valueJSON = JSON.stringify({ nostr: pk }) + const port = 51001 + const fake = startFakeElectrumX(port, name, valueJSON) + try { + const got = await queryProfile('exampleone.bit', [ + { host: 'mock-electrumx.example', port }, + ]) + expect(got).toEqual({ pubkey: pk }) + } finally { + fake.stop() + } +}) + +test('queryProfile: resolves alice@example.bit via extended names map', async () => { + const pk = '8'.repeat(64) + const name = 'd/exampletwo' + const valueJSON = JSON.stringify({ + nostr: { + names: { alice: pk, _: '9'.repeat(64) }, + relays: { [pk]: ['wss://alice.example'] }, + }, + }) + const port = 51002 + const fake = startFakeElectrumX(port, name, valueJSON) + try { + const got = await queryProfile('alice@exampletwo.bit', [ + { host: 'mock-electrumx.example', port }, + ]) + expect(got).toEqual({ pubkey: pk, relays: ['wss://alice.example'] }) + } finally { + fake.stop() + } +}) + +test('queryProfile: returns null for non-namecoin identifiers', async () => { + const got = await queryProfile('alice@example.com', [{ host: 'unused', port: 1 }]) + expect(got).toBeNull() +}) + +/** + * Multi-name variant of {@link startFakeElectrumX}: serves any of the + * registered (name → value) pairs by looking up which scripthash the + * client asked for. Used to exercise the ifa-0001 `import` chain + * end-to-end through {@link queryProfile}. + */ +function startMultiNameFakeElectrumX(port: number, records: Record, pickHeight = 800_000) { + const url = `wss://mock-electrumx.example:${port}` + const server = new Server(url) + + // Pre-compute scripthash → name index. The client builds the + // scripthash from the on-chain name-index script; we mirror that + // here using the same helpers as the production code path. + // Cheaper trick: just round-robin a single name per RPC call by + // tracking which scripthash arrived last. Mock-socket replays the + // request id so we can correlate get_history -> transaction.get. + const nameToHex: Record = {} + for (const [name, value] of Object.entries(records)) { + nameToHex[name] = buildNameUpdateHex(name, value) + } + + // Track which name each session last queried so transaction.get can + // return the matching script. We key by scripthash (the request param + // is unique per name). + const scriptHashToName: Record = {} + + server.on('connection', socket => { + let lastName: string | null = null + socket.on('message', (raw: any) => { + let req: { id?: number; method?: string; params?: unknown[] } + try { + req = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw)) + } catch { + return + } + const id = req.id + const method = req.method + let result: unknown = null + switch (method) { + case 'server.version': + result = ['ElectrumX 1.16.0', '1.4'] + break + case 'blockchain.scripthash.get_history': { + // Resolve scripthash → name by matching against our index. + // We compute names' scripthashes lazily on first call from + // a session by walking the registered map. To keep things + // simple: store a name per scripthash on first sight. + const sh = String((req.params as string[])[0]) + if (!scriptHashToName[sh]) { + // Assume the client iterates names in the same order we + // were registered with; pick the first not-yet-seen name. + for (const n of Object.keys(records)) { + if (!Object.values(scriptHashToName).includes(n)) { + scriptHashToName[sh] = n + break + } + } + } + lastName = scriptHashToName[sh] ?? null + if (lastName === null) { + result = [] + } else { + result = [{ tx_hash: '0'.repeat(64), height: pickHeight }] + } + break + } + case 'blockchain.transaction.get': + if (lastName === null) { + result = { txid: '0'.repeat(64), vout: [] } + } else { + result = { + txid: '0'.repeat(64), + vout: [ + { scriptPubKey: { hex: '76a914' + '0'.repeat(40) + '88ac' } }, + { scriptPubKey: { hex: nameToHex[lastName] } }, + ], + } + } + break + case 'blockchain.headers.subscribe': + result = { height: pickHeight + 10, hex: '' } + break + } + socket.send(JSON.stringify({ jsonrpc: '2.0', id, result })) + }) + }) + + return { server, url, stop: () => server.stop() } +} + +test('queryProfile: resolves through ifa-0001 import chain (importer has no nostr, imported provides it)', async () => { + const pk = 'b'.repeat(64) + // d/importer has only an import pointing at d/lib; d/lib carries the + // nostr block. Without import expansion this would resolve to null. + const importerValue = JSON.stringify({ import: 'd/lib' }) + const libValue = JSON.stringify({ nostr: pk }) + const port = 51005 + const fake = startMultiNameFakeElectrumX(port, { + 'd/importer': importerValue, + 'd/lib': libValue, + }) + try { + const got = await queryProfile('importer.bit', [ + { host: 'mock-electrumx.example', port }, + ]) + expect(got).toEqual({ pubkey: pk }) + } finally { + fake.stop() + } +}) + +test('queryProfile: importer nostr field wins over imported one', async () => { + const pkLocal = 'c'.repeat(64) + const pkImported = 'd'.repeat(64) + const importerValue = JSON.stringify({ import: 'd/lib', nostr: pkLocal }) + const libValue = JSON.stringify({ nostr: pkImported }) + const port = 51006 + const fake = startMultiNameFakeElectrumX(port, { + 'd/importer2': importerValue, + 'd/lib': libValue, + }) + try { + const got = await queryProfile('importer2.bit', [ + { host: 'mock-electrumx.example', port }, + ]) + expect(got).toEqual({ pubkey: pkLocal }) + } finally { + fake.stop() + } +}) + +test('queryProfile: a record without `import` triggers zero extra lookups', async () => { + // Regression: the import path must short-circuit when there's no + // `import` key. Use a fake server that fails any second name lookup. + const pk = 'e'.repeat(64) + const port = 51007 + const fake = startFakeElectrumX(port, 'd/standalone', JSON.stringify({ nostr: pk })) + try { + const got = await queryProfile('standalone.bit', [ + { host: 'mock-electrumx.example', port }, + ]) + expect(got).toEqual({ pubkey: pk }) + } finally { + fake.stop() + } +}) + +test('queryProfile: falls back to the second server on transport error', async () => { + const pk = 'a'.repeat(64) + const name = 'd/examplethree' + const valueJSON = JSON.stringify({ nostr: pk }) + const port2 = 51004 + const fake2 = startFakeElectrumX(port2, name, valueJSON) + try { + // First server doesn't exist (mock-socket will fail to connect), + // second one answers. The tested code should fall through. + const got = await queryProfile('examplethree.bit', [ + { host: 'does-not-exist.example', port: 51003 }, + { host: 'mock-electrumx.example', port: port2 }, + ]) + expect(got).toEqual({ pubkey: pk }) + } finally { + fake2.stop() + } +}) diff --git a/nip05namecoin.ts b/nip05namecoin.ts new file mode 100644 index 0000000..6204870 --- /dev/null +++ b/nip05namecoin.ts @@ -0,0 +1,662 @@ +/** + * NIP-05 resolution for Namecoin `.bit` identifiers. + * + * Mirrors the shape of `./nip05.ts` so call sites can chain the two: + * + * import * as namecoin from 'nostr-tools/nip05namecoin' + * import * as nip05 from 'nostr-tools/nip05' + * + * const profile = namecoin.isValidIdentifier(input) + * ? await namecoin.queryProfile(input) + * : await nip05.queryProfile(input) + * + * Accepted identifier shapes: + * + * - `alice@example.bit` + * - `example.bit` (uses the `_` root entry) + * - `d/example` (domain namespace) + * - `id/alice` (identity namespace) + * - a `nostr:` NIP-21 URI prefix is tolerated + * + * Resolution walks the Namecoin blockchain via a public ElectrumX + * server (WebSocket-over-TLS transport): finds the most recent + * `name_update` transaction for `d/` or `id/`, parses + * its value JSON, and extracts the `nostr` field (either the simple + * `"hex-pubkey"` form or the object `{names: {...}, relays: {...}}` + * form used by Amethyst and the `.bit` NIP-05 spec draft). + * + * Ported from the Go reference at + * https://github.com/mstrofnone/nostrlib-nip05-namecoin (itself a + * port of the Kotlin implementation in Amethyst and the Swift port + * in Nostur). + * + * # Transport notes + * + * This module speaks JSON-RPC over WSS to an ElectrumX server. By + * default it uses the global `WebSocket` (native in browsers and + * Node 22+). In Node you can plug in a different implementation via + * `useWebSocketImplementation` — for example, the `ws` package + * combined with a custom `tls` agent that pins the server's + * self-signed certificate. + * + * The shipped {@link DEFAULT_ELECTRUMX_SERVERS} list points at the + * two long-running public Namecoin ElectrumX operators, both of + * which serve self-signed certs today. In a browser those will + * therefore fail TLS until either operator switches to a CA-issued + * cert or the caller provides a WebSocket implementation that + * trusts the pinned cert. + */ + +import { sha256 } from '@noble/hashes/sha2.js' +import { bytesToHex } from '@noble/hashes/utils.js' + +import { expandImports, type NameValueFetcher } from './nip05namecoin-import.ts' +import { ProfilePointer } from './nip19.ts' + +/** A pluggable WebSocket implementation. Must match the browser API. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type WebSocketCtor = any + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let _WebSocket: WebSocketCtor +try { + _WebSocket = WebSocket +} catch (_) { + null +} + +/** + * Inject a WebSocket implementation. Useful in Node < 22, or when you + * need to pin self-signed certs (pass `ws` constructed with a custom + * `tls.Agent`, or a wrapper around it). + */ +export function useWebSocketImplementation(impl: WebSocketCtor): void { + _WebSocket = impl +} + +/** A single Namecoin ElectrumX endpoint. */ +export type ElectrumXServer = { + /** Hostname, e.g. `electrumx.testls.space`. */ + host: string + /** Port, e.g. `50004` for the WSS endpoint. */ + port: number + /** WSS path. Defaults to `/`. */ + path?: string +} + +/** + * Default list of Namecoin ElectrumX WSS endpoints, tried in order. + * + * Note: both operators serve **self-signed** TLS certificates as of + * this writing. Browsers will refuse the handshake. In Node, supply + * a WebSocket implementation (via {@link useWebSocketImplementation}) + * that trusts the pinned certs — see the README — or run your own + * ElectrumX instance with a CA-issued cert. + */ +export const DEFAULT_ELECTRUMX_SERVERS: ElectrumXServer[] = [ + { host: 'electrumx.testls.space', port: 50004 }, + { host: 'nmc2.bitcoins.sk', port: 57004 }, + { host: '46.229.238.187', port: 57004 }, +] + +/** Blocks until a Namecoin name expires (~250 days). */ +const NAME_EXPIRE_DEPTH = 36000 + +const HEX_PUBKEY_RE = /^[0-9a-fA-F]{64}$/ + +/** + * Returns `true` when `identifier` should be routed to Namecoin + * resolution instead of DNS-based NIP-05. Match targets: + * + * - `.bit` + * - `alice@.bit` + * - `d/` + * - `id/` + * + * The match is intentionally cheap: callers use it as a front-door + * check in hot paths. + */ +export function isValidIdentifier(identifier?: string | null): boolean { + if (!identifier) return false + let s = identifier.trim().toLowerCase() + if (s.startsWith('nostr:')) s = s.slice(6) + if (s.startsWith('d/') || s.startsWith('id/')) return true + return s.endsWith('.bit') +} + +/** Alias for {@link isValidIdentifier}. */ +export const isDotBit = isValidIdentifier + +type ParsedIdentifier = { + /** The underlying Namecoin name, e.g. `d/example` or `id/alice`. */ + namecoinName: string + /** The local-part within the name value, e.g. `alice` or `_`. */ + localPart: string + /** True for `d/` names (domain + `names` map), false for `id/` names. */ + isDomain: boolean +} + +function parseIdentifier(raw: string): ParsedIdentifier | null { + let input = raw.trim() + if (input.length >= 6 && input.slice(0, 6).toLowerCase() === 'nostr:') { + input = input.slice(6) + } + const lower = input.toLowerCase() + + if (lower.startsWith('d/')) { + return { namecoinName: lower, localPart: '_', isDomain: true } + } + if (lower.startsWith('id/')) { + return { namecoinName: lower, localPart: '_', isDomain: false } + } + + // user@domain.bit + if (input.includes('@') && lower.endsWith('.bit')) { + const atIdx = input.indexOf('@') + const local = input.slice(0, atIdx).toLowerCase() || '_' + const domain = input + .slice(atIdx + 1) + .toLowerCase() + .replace(/\.bit$/, '') + if (!domain) return null + return { namecoinName: 'd/' + domain, localPart: local, isDomain: true } + } + + // bare.bit + if (lower.endsWith('.bit')) { + const domain = lower.replace(/\.bit$/, '') + if (!domain) return null + return { namecoinName: 'd/' + domain, localPart: '_', isDomain: true } + } + + return null +} + +/** + * Resolve a `.bit` / `d/` / `id/` identifier to a Nostr + * {@link ProfilePointer}. + * + * Returns `null` if the identifier shape is invalid, the name is not + * registered (or expired), the name value lacks a valid `nostr` field, + * or every configured server failed to respond. + * + * Network errors and blockchain-level negatives are treated the same + * (returning `null`) so that the signature matches + * {@link queryProfile} from `./nip05.ts`. + */ +export async function queryProfile( + identifier: string, + servers: ElectrumXServer[] = DEFAULT_ELECTRUMX_SERVERS, +): Promise { + const parsed = parseIdentifier(identifier) + if (!parsed) return null + + const valueJSON = await nameShowWithFallback(parsed.namecoinName, servers) + if (!valueJSON) return null + + // Parse the on-chain value, then expand any ifa-0001 `import` items + // by fetching the referenced names through the same server pool. + // Records without `"import"` short-circuit inside expandImports (no + // extra I/O), so this is free for the common case. + let parsedRoot: Record + try { + parsedRoot = JSON.parse(valueJSON) as Record + } catch { + return null + } + if (typeof parsedRoot !== 'object' || parsedRoot === null) return null + + const importFetcher: NameValueFetcher = async (importedName: string) => { + try { + return await nameShowWithFallback(importedName, servers) + } catch { + // Best-effort: import failures contribute nothing (matches + // Amethyst's NamecoinNameResolver.expandImportsIfPresent). + return null + } + } + const merged = await expandImports(parsedRoot, importFetcher) + + const extracted = extractNostrFromObject(merged, parsed) + if (!extracted) return null + + const { pubkey, relays } = extracted + const pointer: ProfilePointer = { pubkey } + if (relays && relays.length > 0) pointer.relays = relays + return pointer +} + +/** + * Like `nip05.isValid` but for `.bit` identifiers. Returns `false` on + * any lookup failure. + */ +export async function isValid(pubkey: string, identifier: string): Promise { + const res = await queryProfile(identifier) + return res ? res.pubkey === pubkey : false +} + +// --------------------------------------------------------------------------- +// ElectrumX transport (WSS) +// --------------------------------------------------------------------------- + +/** Namecoin script opcodes used by the name-index script. */ +const OP_NAME_UPDATE = 0x53 // OP_3, repurposed by Namecoin as OP_NAME_UPDATE +const OP_2DROP = 0x6d +const OP_DROP = 0x75 +const OP_RETURN = 0x6a +const OP_PUSHDATA1 = 0x4c +const OP_PUSHDATA2 = 0x4d +const OP_PUSHDATA4 = 0x4e + +const textEncoder = new TextEncoder() + +/** + * Try each server in order until one returns the raw JSON value for + * `name`. Returns `null` if the name was definitively not found, and + * also if every server errored out (we don't distinguish those in the + * public API). + */ +async function nameShowWithFallback(name: string, servers: ElectrumXServer[]): Promise { + let foundDefinitiveMiss = false + for (const srv of servers) { + try { + const val = await nameShow(name, srv) + return val // may be null if server said "no such name" + } catch (err) { + if (err instanceof NameMissError) { + foundDefinitiveMiss = true + // Keep trying other servers: a definitive miss from one server + // is only authoritative if they agree. + continue + } + // Transport error — try next server. + } + } + if (foundDefinitiveMiss) return null + return null +} + +class NameMissError extends Error {} + +/** + * Open a short-lived WSS connection, run the name_show flow, and + * return the raw JSON value (the string stored against `name` on + * chain), or `null` if the name is not registered / has expired. + * + * Throws on transport-level failures so the caller can try the next + * server. + */ +async function nameShow(name: string, srv: ElectrumXServer): Promise { + if (!_WebSocket) { + throw new Error( + 'nip05namecoin: no WebSocket implementation available. In Node < 22, call useWebSocketImplementation(impl).', + ) + } + + const url = buildWSSUrl(srv) + const rpc = new RPC(new _WebSocket(url)) + try { + await rpc.opened + + // 1. Negotiate protocol version. Response discarded; we only + // care the socket is alive. + await rpc.call('server.version', ['nostr-tools/nip05namecoin', '1.4']) + + // 2. Ask for the name's transaction history. + const script = buildNameIndexScript(textEncoder.encode(name)) + const scriptHash = electrumScriptHash(script) + const history = await rpc.call>( + 'blockchain.scripthash.get_history', + [scriptHash], + ) + if (!history || history.length === 0) throw new NameMissError() + const latest = history[history.length - 1] + + // 3. Fetch the full (verbose) transaction. + const tx = await rpc.call<{ vout: Array<{ scriptPubKey?: { hex?: string } }> }>( + 'blockchain.transaction.get', + [latest.tx_hash, true], + ) + + // 4. Get current block height for expiry check. + let currentHeight = 0 + try { + const header = await rpc.call<{ height?: number }>('blockchain.headers.subscribe', []) + if (header && typeof header.height === 'number') currentHeight = header.height + } catch { + // Non-fatal: just skip the expiry check. + } + + if (currentHeight > 0 && latest.height > 0 && currentHeight - latest.height >= NAME_EXPIRE_DEPTH) { + // Expired — treat as a miss. + return null + } + + return extractNameValue(tx.vout, name) + } finally { + rpc.close() + } +} + +function buildWSSUrl(srv: ElectrumXServer): string { + const path = srv.path ?? '/' + return `wss://${srv.host}:${srv.port}${path.startsWith('/') ? path : '/' + path}` +} + +/** Minimal JSON-RPC-2.0 over WebSocket, one in-flight call at a time. */ +class RPC { + opened: Promise + private ws: WebSocket + private id = 0 + private pending = new Map void; reject: (e: Error) => void }>() + + constructor(ws: WebSocket) { + this.ws = ws + this.opened = new Promise((resolve, reject) => { + ws.addEventListener('open', () => resolve()) + ws.addEventListener('error', () => reject(new Error('websocket error'))) + ws.addEventListener('close', () => { + // Reject any outstanding calls. + for (const p of this.pending.values()) p.reject(new Error('websocket closed')) + this.pending.clear() + }) + }) + ws.addEventListener('message', ev => this.onMessage(ev)) + } + + async call(method: string, params: unknown[]): Promise { + const id = ++this.id + const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve: v => resolve(v as T), reject }) + try { + this.ws.send(msg) + } catch (e) { + this.pending.delete(id) + reject(e instanceof Error ? e : new Error(String(e))) + } + }) + } + + private onMessage(ev: MessageEvent): void { + let parsed: { id?: number; result?: unknown; error?: unknown } + try { + const data = typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data as ArrayBuffer) + parsed = JSON.parse(data) + } catch { + return + } + if (typeof parsed.id !== 'number') return + const p = this.pending.get(parsed.id) + if (!p) return + this.pending.delete(parsed.id) + if (parsed.error) { + p.reject(new Error(typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error))) + } else { + p.resolve(parsed.result) + } + } + + close(): void { + try { + this.ws.close() + } catch { + // ignore + } + } +} + +// --------------------------------------------------------------------------- +// Namecoin script: build index script, parse NAME_UPDATE vout +// --------------------------------------------------------------------------- + +/** + * Build the canonical script used by the Namecoin ElectrumX fork to + * index names on-chain: + * + * OP_NAME_UPDATE OP_2DROP OP_DROP OP_RETURN + * + * The SHA-256 of this script (reversed, hex-encoded) is the scripthash + * queried via `blockchain.scripthash.get_history`. + */ +function buildNameIndexScript(nameBytes: Uint8Array): Uint8Array { + const parts: number[] = [] + parts.push(OP_NAME_UPDATE) + pushData(parts, nameBytes) + pushData(parts, new Uint8Array(0)) + parts.push(OP_2DROP, OP_DROP, OP_RETURN) + return new Uint8Array(parts) +} + +function pushData(out: number[], data: Uint8Array): void { + const n = data.length + if (n < OP_PUSHDATA1) { + out.push(n) + } else if (n <= 0xff) { + out.push(OP_PUSHDATA1, n) + } else { + out.push(OP_PUSHDATA2, n & 0xff, (n >> 8) & 0xff) + } + for (let i = 0; i < n; i++) out.push(data[i]) +} + +/** SHA-256 of `script`, byte-reversed, hex-encoded. */ +function electrumScriptHash(script: Uint8Array): string { + const digest = sha256(script) + const reversed = new Uint8Array(digest.length) + for (let i = 0; i < digest.length; i++) reversed[i] = digest[digest.length - 1 - i] + return bytesToHex(reversed) +} + +/** Walk vouts looking for a NAME_UPDATE that matches `name`. */ +function extractNameValue(vouts: Array<{ scriptPubKey?: { hex?: string } }>, name: string): string | null { + for (const vout of vouts || []) { + const hex = vout?.scriptPubKey?.hex + if (!hex || !hex.startsWith('53')) continue // not a NAME_UPDATE + let bytes: Uint8Array + try { + bytes = hexToBytes(hex) + } catch { + continue + } + const parsed = parseNameScript(bytes) + if (!parsed) continue + if (parsed.name === name) return parsed.value + } + return null +} + +/** Parse a NAME_UPDATE output script. */ +function parseNameScript(script: Uint8Array): { name: string; value: string } | null { + if (script.length === 0 || script[0] !== OP_NAME_UPDATE) return null + let pos = 1 + const nameRead = readPushData(script, pos) + if (!nameRead) return null + pos = nameRead.next + const valueRead = readPushData(script, pos) + if (!valueRead) return null + const decoder = new TextDecoder('utf-8', { fatal: false }) + return { + name: decoder.decode(nameRead.data), + value: decoder.decode(valueRead.data), + } +} + +function readPushData(script: Uint8Array, pos: number): { data: Uint8Array; next: number } | null { + if (pos >= script.length) return null + const op = script[pos] + + if (op === 0x00) { + return { data: new Uint8Array(0), next: pos + 1 } + } + if (op < OP_PUSHDATA1) { + const length = op + const end = pos + 1 + length + if (end > script.length) return null + return { data: script.slice(pos + 1, end), next: end } + } + if (op === OP_PUSHDATA1) { + if (pos + 2 > script.length) return null + const length = script[pos + 1] + const end = pos + 2 + length + if (end > script.length) return null + return { data: script.slice(pos + 2, end), next: end } + } + if (op === OP_PUSHDATA2) { + if (pos + 3 > script.length) return null + const length = script[pos + 1] | (script[pos + 2] << 8) + const end = pos + 3 + length + if (end > script.length) return null + return { data: script.slice(pos + 3, end), next: end } + } + if (op === OP_PUSHDATA4) { + if (pos + 5 > script.length) return null + const length = + script[pos + 1] | + (script[pos + 2] << 8) | + (script[pos + 3] << 16) | + (script[pos + 4] << 24) + const end = pos + 5 + length + if (end < 0 || end > script.length) return null + return { data: script.slice(pos + 5, end), next: end } + } + return null +} + +function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0) throw new Error('hex: odd length') + const out = new Uint8Array(hex.length / 2) + for (let i = 0; i < out.length; i++) { + const b = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + if (Number.isNaN(b)) throw new Error('hex: invalid byte') + out[i] = b + } + return out +} + +// --------------------------------------------------------------------------- +// JSON value extraction +// --------------------------------------------------------------------------- + +/** + * Pull the `nostr` pubkey and optional relay list out of a Namecoin + * name value. Supports the simple `"nostr": "hex"` form and the + * extended `"nostr": { "names": {...}, "relays": {...} }` form used + * by Amethyst. + * + * Returns `null` if no valid pubkey matches the requested local-part. + */ +export function extractNostrFromValue( + valueJSON: string, + parsed: ParsedIdentifier, +): { pubkey: string; relays?: string[] } | null { + let root: Record + try { + root = JSON.parse(valueJSON) as Record + } catch { + return null + } + if (typeof root !== 'object' || root === null) return null + return extractNostrFromObject(root, parsed) +} + +/** + * Same as {@link extractNostrFromValue} but takes a pre-parsed object. + * Internal: used by the resolver after expanding ifa-0001 `import` + * chains, so we don't re-stringify and re-parse the merged record. + */ +function extractNostrFromObject( + root: Record, + parsed: ParsedIdentifier, +): { pubkey: string; relays?: string[] } | null { + const nostrField = root['nostr'] + if (nostrField === undefined || nostrField === null) return null + + // Simple form: "nostr": "hex-pubkey" + if (typeof nostrField === 'string') { + if (parsed.isDomain && parsed.localPart !== '_') return null + if (!HEX_PUBKEY_RE.test(nostrField)) return null + return { pubkey: nostrField.toLowerCase() } + } + + // Extended form: object with "names" and optional "relays". + if (typeof nostrField !== 'object') return null + const obj = nostrField as Record + + if (parsed.isDomain) { + return extractFromDomainNamesObject(obj, parsed) + } + return extractFromIdentityObject(obj, parsed) +} + +function extractFromDomainNamesObject( + obj: Record, + parsed: ParsedIdentifier, +): { pubkey: string; relays?: string[] } | null { + const names = obj['names'] + if (typeof names !== 'object' || names === null) return null + const namesMap = names as Record + + let pickedPubkey: string | null = null + + const exact = namesMap[parsed.localPart] + if (typeof exact === 'string' && HEX_PUBKEY_RE.test(exact)) { + pickedPubkey = exact + } else { + const underscore = namesMap['_'] + if (typeof underscore === 'string' && HEX_PUBKEY_RE.test(underscore)) { + pickedPubkey = underscore + } else if (parsed.localPart === '_') { + for (const v of Object.values(namesMap)) { + if (typeof v === 'string' && HEX_PUBKEY_RE.test(v)) { + pickedPubkey = v + break + } + } + } + } + + if (!pickedPubkey) return null + const relays = extractRelays(obj, pickedPubkey) + return relays ? { pubkey: pickedPubkey.toLowerCase(), relays } : { pubkey: pickedPubkey.toLowerCase() } +} + +function extractFromIdentityObject( + obj: Record, + _parsed: ParsedIdentifier, +): { pubkey: string; relays?: string[] } | null { + // Try "pubkey" field first (id/ shape). + const pk = obj['pubkey'] + if (typeof pk === 'string' && HEX_PUBKEY_RE.test(pk)) { + const relaysRaw = obj['relays'] + if (Array.isArray(relaysRaw)) { + const relays = relaysRaw.filter((r): r is string => typeof r === 'string') + return relays.length > 0 ? { pubkey: pk.toLowerCase(), relays } : { pubkey: pk.toLowerCase() } + } + return { pubkey: pk.toLowerCase() } + } + + // Fall back to NIP-05-like "names" with "_" root. + const names = obj['names'] + if (typeof names === 'object' && names !== null) { + const underscore = (names as Record)['_'] + if (typeof underscore === 'string' && HEX_PUBKEY_RE.test(underscore)) { + const relays = extractRelays(obj, underscore) + return relays + ? { pubkey: underscore.toLowerCase(), relays } + : { pubkey: underscore.toLowerCase() } + } + } + + return null +} + +function extractRelays(obj: Record, pubkey: string): string[] | null { + const raw = obj['relays'] + if (typeof raw !== 'object' || raw === null) return null + const map = raw as Record + const candidate = map[pubkey.toLowerCase()] ?? map[pubkey] + if (!Array.isArray(candidate)) return null + const relays = candidate.filter((r): r is string => typeof r === 'string') + return relays.length > 0 ? relays : null +} diff --git a/package.json b/package.json index ce76104..47a15ea 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,18 @@ "require": "./lib/cjs/nip05.js", "types": "./lib/types/nip05.d.ts" }, + "./nip05namecoin": { + "source": "./nip05namecoin.ts", + "import": "./lib/esm/nip05namecoin.js", + "require": "./lib/cjs/nip05namecoin.js", + "types": "./lib/types/nip05namecoin.d.ts" + }, + "./nip05namecoin-node": { + "source": "./nip05namecoin-node.ts", + "import": "./lib/esm/nip05namecoin-node.js", + "require": "./lib/cjs/nip05namecoin-node.js", + "types": "./lib/types/nip05namecoin-node.d.ts" + }, "./nip06": { "source": "./nip06.ts", "import": "./lib/esm/nip06.js", @@ -288,11 +300,15 @@ "nostr-wasm": "0.1.0" }, "peerDependencies": { - "typescript": ">=5.0.0" + "typescript": ">=5.0.0", + "ws": ">=8.0.0" }, "peerDependenciesMeta": { "typescript": { "optional": true + }, + "ws": { + "optional": true } }, "keywords": [ @@ -305,6 +321,7 @@ "devDependencies": { "@types/node": "^18.13.0", "@types/node-fetch": "^2.6.3", + "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^6.5.0", "@typescript-eslint/parser": "^6.5.0", "bun-types": "^1.0.18", @@ -316,7 +333,8 @@ "mock-socket": "^9.3.1", "node-fetch": "^2.6.9", "prettier": "^3.0.3", - "typescript": "^5.8.2" + "typescript": "^5.8.2", + "ws": "^8.20.0" }, "scripts": { "prepublish": "just build"