-
Notifications
You must be signed in to change notification settings - Fork 26
feat: NIP-05/Namecoin (.bit) verification plugin #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mstrofnone
wants to merge
3
commits into
quentintaranpino:main
Choose a base branch
from
mstrofnone:feat/nip05-namecoin
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| // namecoinNIP05 — opt-in NIP-05/Namecoin (`.bit`) verification. | ||
| // | ||
| // Composes with the existing `activeNIP05` plugin: | ||
| // | ||
| // - `activeNIP05` checks DNS-based NIP-05 (e.g. `alice@example.com`) against | ||
| // the pubkey's published kind-0 metadata. | ||
| // - `namecoinNIP05` checks `.bit` (Namecoin) NIP-05 the same way, but uses | ||
| // public ElectrumX servers as the source of truth instead of DNS. | ||
| // | ||
| // The two are mutually exclusive on a per-pubkey basis: an identifier is | ||
| // either a `.bit` Namecoin name or a DNS name. Non-Namecoin identifiers | ||
| // pass through this plugin (return `true`) so the existing DNS chain still | ||
| // runs and is responsible for the verdict. | ||
| // | ||
| // Disabled by default. Operators enable it via the existing plugins config | ||
| // UI / config file (the plugin loader writes `enabled: false` on first | ||
| // discovery; flip it to `true` to activate). | ||
|
|
||
| import { resolveNamecoinNIP05, isNamecoinIdentifier } from "../dist/lib/nostr/NIP05Namecoin.js"; | ||
|
|
||
| function plugin() { | ||
| return { | ||
| order: 2, | ||
| name: 'namecoinNIP05', | ||
| module: '', | ||
| execute: async (input, globals) => { | ||
| try { | ||
| if (!input || typeof input.pubkey !== 'string' || !input.pubkey) { | ||
| return true; | ||
| } | ||
|
|
||
| // Read the pubkey's currently published kind-0 metadata. We | ||
| // mirror activeNIP05's caching strategy exactly so the two | ||
| // plugins share the same redis key and don't double-fetch. | ||
| let pubkeyMetadata = JSON.parse(await globals.redis.get(`pubkeyMetadata-${input.pubkey}`)); | ||
| if (!pubkeyMetadata) { | ||
| pubkeyMetadata = await globals.nostr.NIP01.getPubkeyMetadata(input.pubkey); | ||
| await globals.redis.set(`pubkeyMetadata-${input.pubkey}`, JSON.stringify(pubkeyMetadata), { EX: 3600 }); | ||
| } | ||
|
|
||
| const claimed = (pubkeyMetadata && pubkeyMetadata.nip05) ? String(pubkeyMetadata.nip05).trim() : ""; | ||
| if (!claimed) { | ||
| // No NIP-05 claim at all — leave the verdict to DNS-side | ||
| // plugins. activeNIP05 will return false; namecoinNIP05 | ||
| // has nothing to say. | ||
| return true; | ||
| } | ||
|
|
||
| // Not a Namecoin identifier — pass through so DNS NIP-05 | ||
| // checks (activeNIP05) handle the verdict. | ||
| if (!isNamecoinIdentifier(claimed)) { | ||
| return true; | ||
| } | ||
|
|
||
| // Namecoin identifier — resolve on-chain and compare. | ||
| const resolved = await resolveNamecoinNIP05(claimed); | ||
| if (!resolved || typeof resolved.pubkey !== 'string') { | ||
| globals.logger.warn(`namecoinNIP05 - could not resolve ${claimed} for pubkey ${input.pubkey}`); | ||
| return false; | ||
| } | ||
|
|
||
| return resolved.pubkey.toLowerCase() === input.pubkey.toLowerCase(); | ||
|
|
||
| } catch (error) { | ||
| globals.logger.error('Error executing namecoinNIP05 plugin', error); | ||
| return false; | ||
| } | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| export default plugin; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,278 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { | ||
| DEFAULT_ELECTRUMX_SERVERS, | ||
| PINNED_ELECTRUMX_CERTS, | ||
| buildNameIndexScript, | ||
| electrumScriptHash, | ||
| extractPubkeyFromNamecoinValue, | ||
| isNamecoinIdentifier, | ||
| parseIdentifier, | ||
| parseNameUpdateScript, | ||
| resolveNamecoinNIP05, | ||
| } from "./NIP05Namecoin.js"; | ||
|
|
||
| const PK1 = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"; | ||
| const PK2 = "6cdebcca8b8b9f5e1ab3b3aa1d2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2"; | ||
|
|
||
| // ----------------------------------------------------------------------------- | ||
| // isNamecoinIdentifier — front-door cheap check | ||
| // ----------------------------------------------------------------------------- | ||
|
|
||
| describe("isNamecoinIdentifier", () => { | ||
| it("matches the expected shapes", () => { | ||
| expect(isNamecoinIdentifier("example.bit")).toBe(true); | ||
| expect(isNamecoinIdentifier("alice@example.bit")).toBe(true); | ||
| expect(isNamecoinIdentifier("d/example")).toBe(true); | ||
| expect(isNamecoinIdentifier("id/alice")).toBe(true); | ||
| expect(isNamecoinIdentifier("nostr:alice@example.bit")).toBe(true); | ||
| expect(isNamecoinIdentifier(" EXAMPLE.BIT ")).toBe(true); | ||
| }); | ||
|
|
||
| it("rejects DNS / empty / non-string inputs", () => { | ||
| expect(isNamecoinIdentifier("")).toBe(false); | ||
| expect(isNamecoinIdentifier("alice@example.com")).toBe(false); | ||
| expect(isNamecoinIdentifier("example.com")).toBe(false); | ||
| // intentionally cheap: `d/` answers "route this through Namecoin" | ||
| // without strict validation. parseIdentifier is the strict gate. | ||
| expect(isNamecoinIdentifier("d/")).toBe(true); | ||
| expect(parseIdentifier("d/")).toBeNull(); | ||
| expect(parseIdentifier("id/")).toBeNull(); | ||
| expect(parseIdentifier(".bit")).toBeNull(); | ||
| // non-string is rejected at the type guard | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| expect(isNamecoinIdentifier(null as any)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| // ----------------------------------------------------------------------------- | ||
| // parseIdentifier — parser spec (rust-nostr 1:1) | ||
| // ----------------------------------------------------------------------------- | ||
|
|
||
| describe("parseIdentifier", () => { | ||
| it("parses user@domain.bit", () => { | ||
| const addr = parseIdentifier("alice@example.bit"); | ||
| expect(addr).not.toBeNull(); | ||
| expect(addr!.namecoinName).toBe("d/example"); | ||
| expect(addr!.namespace).toBe("d/example"); | ||
| expect(addr!.localPart).toBe("alice"); | ||
| expect(addr!.isDomain).toBe(true); | ||
| }); | ||
|
|
||
| it("parses bare domain.bit", () => { | ||
| const addr = parseIdentifier("example.bit"); | ||
| expect(addr!.namecoinName).toBe("d/example"); | ||
| expect(addr!.localPart).toBe("_"); | ||
| expect(addr!.isDomain).toBe(true); | ||
| }); | ||
|
|
||
| it("parses d/<name>", () => { | ||
| const addr = parseIdentifier("d/example"); | ||
| expect(addr!.namecoinName).toBe("d/example"); | ||
| expect(addr!.localPart).toBe("_"); | ||
| expect(addr!.isDomain).toBe(true); | ||
| }); | ||
|
|
||
| it("parses id/<name>", () => { | ||
| const addr = parseIdentifier("id/alice"); | ||
| expect(addr!.namecoinName).toBe("id/alice"); | ||
| expect(addr!.localPart).toBe("_"); | ||
| expect(addr!.isDomain).toBe(false); | ||
| }); | ||
|
|
||
| it("strips a leading nostr: prefix", () => { | ||
| const addr = parseIdentifier("nostr:alice@example.bit"); | ||
| expect(addr!.namecoinName).toBe("d/example"); | ||
| expect(addr!.localPart).toBe("alice"); | ||
| }); | ||
|
|
||
| it("is case-insensitive", () => { | ||
| const addr = parseIdentifier("ALICE@EXAMPLE.BIT"); | ||
| expect(addr!.namecoinName).toBe("d/example"); | ||
| expect(addr!.localPart).toBe("alice"); | ||
| }); | ||
|
|
||
| it("normalises empty local-part to _", () => { | ||
| const addr = parseIdentifier("@example.bit"); | ||
| expect(addr).not.toBeNull(); | ||
| expect(addr!.localPart).toBe("_"); | ||
| expect(addr!.namecoinName).toBe("d/example"); | ||
| }); | ||
|
|
||
| it("rejects garbage / non-bit DNS / empty namespaces", () => { | ||
| expect(parseIdentifier("alice@example.com")).toBeNull(); | ||
| expect(parseIdentifier("")).toBeNull(); | ||
| expect(parseIdentifier(".bit")).toBeNull(); | ||
| expect(parseIdentifier("d/")).toBeNull(); | ||
| expect(parseIdentifier("id/")).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| // ----------------------------------------------------------------------------- | ||
| // extractPubkeyFromNamecoinValue — JSON shape handling | ||
| // ----------------------------------------------------------------------------- | ||
|
|
||
| describe("extractPubkeyFromNamecoinValue", () => { | ||
| it("handles the simple form for root identifiers", () => { | ||
| const value = `{ "nostr": "${PK1}" }`; | ||
| const r = extractPubkeyFromNamecoinValue(value, "_"); | ||
| expect(r).not.toBeNull(); | ||
| expect(r!.pubkey).toBe(PK1); | ||
| expect(r!.relays).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("rejects the simple form when a local-part is requested", () => { | ||
| const value = `{ "nostr": "${PK1}" }`; | ||
| expect(extractPubkeyFromNamecoinValue(value, "alice")).toBeNull(); | ||
| }); | ||
|
|
||
| it("handles extended-form exact name match + relays", () => { | ||
| const value = JSON.stringify({ | ||
| nostr: { | ||
| names: { _: PK1, alice: PK2 }, | ||
| relays: { [PK2]: ["wss://relay.example.com"] }, | ||
| }, | ||
| }); | ||
| const r = extractPubkeyFromNamecoinValue(value, "alice"); | ||
| expect(r).not.toBeNull(); | ||
| expect(r!.pubkey).toBe(PK2); | ||
| expect(r!.relays).toEqual(["wss://relay.example.com"]); | ||
| }); | ||
|
|
||
| it("falls back to the _ root entry when the local-part is missing", () => { | ||
| const value = JSON.stringify({ nostr: { names: { _: PK1 } } }); | ||
| const r = extractPubkeyFromNamecoinValue(value, "ghost"); | ||
| expect(r!.pubkey).toBe(PK1); | ||
| }); | ||
|
|
||
| it("falls back to the first valid pubkey only when the caller asked for _", () => { | ||
| const value = JSON.stringify({ nostr: { names: { alice: PK1 } } }); | ||
| // local-part != "_" must not bleed onto random entries | ||
| expect(extractPubkeyFromNamecoinValue(value, "ghost")).toBeNull(); | ||
| // when asking for the root, the first valid hex is fine | ||
| const r = extractPubkeyFromNamecoinValue(value, "_"); | ||
| expect(r!.pubkey).toBe(PK1); | ||
| }); | ||
|
|
||
| it("supports the identity-namespace pubkey field", () => { | ||
| const value = JSON.stringify({ | ||
| nostr: { pubkey: PK1, relays: ["wss://relay.example.com"] }, | ||
| }); | ||
| const r = extractPubkeyFromNamecoinValue(value, "_"); | ||
| expect(r!.pubkey).toBe(PK1); | ||
| expect(r!.relays).toEqual(["wss://relay.example.com"]); | ||
| }); | ||
|
|
||
| it("tolerates a nostr: prefix on the local-part", () => { | ||
| const value = JSON.stringify({ nostr: { names: { alice: PK1 } } }); | ||
| const r = extractPubkeyFromNamecoinValue(value, "nostr:alice"); | ||
| expect(r!.pubkey).toBe(PK1); | ||
| }); | ||
|
|
||
| it("returns null on missing or non-hex nostr fields", () => { | ||
| expect(extractPubkeyFromNamecoinValue('{ "ip": "1.2.3.4" }', "_")).toBeNull(); | ||
| expect(extractPubkeyFromNamecoinValue('{ "nostr": "not-hex" }', "_")).toBeNull(); | ||
| expect(extractPubkeyFromNamecoinValue("not-json", "_")).toBeNull(); | ||
| expect(extractPubkeyFromNamecoinValue("", "_")).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| // ----------------------------------------------------------------------------- | ||
| // Script + scripthash helpers | ||
| // ----------------------------------------------------------------------------- | ||
|
|
||
| describe("name index script + scripthash", () => { | ||
| it("builds the name index script layout", () => { | ||
| const script = buildNameIndexScript("d/example"); | ||
| expect(script[0]).toBe(0x53); // OP_NAME_UPDATE | ||
| expect(script[1]).toBe(9); // push length for "d/example" | ||
| expect(script.subarray(2, 11).toString("utf8")).toBe("d/example"); | ||
| expect(script[11]).toBe(0x00); // empty push | ||
| expect(script[12]).toBe(0x6d); // OP_2DROP | ||
| expect(script[13]).toBe(0x75); // OP_DROP | ||
| expect(script[14]).toBe(0x6a); // OP_RETURN | ||
| }); | ||
|
|
||
| it("computes a reversed-sha256 lowercase hex scripthash", () => { | ||
| const h = electrumScriptHash(buildNameIndexScript("d/example")); | ||
| expect(h).toHaveLength(64); | ||
| expect(/^[0-9a-f]{64}$/.test(h)).toBe(true); | ||
| }); | ||
|
|
||
| it("round-trips parseNameUpdateScript with a small value", () => { | ||
| // OP_NAME_UPDATE push("d/example") push("{}") OP_2DROP OP_DROP <addr> | ||
| const parts: number[] = []; | ||
| parts.push(0x53); | ||
| parts.push(9); | ||
| parts.push(..."d/example".split("").map((c) => c.charCodeAt(0))); | ||
| parts.push(2); | ||
| parts.push(..."{}".split("").map((c) => c.charCodeAt(0))); | ||
| parts.push(0x6d, 0x75); | ||
| parts.push(0x76, 0xa9, 0x14, 0xde, 0xad, 0xbe, 0xef); | ||
| const decoded = parseNameUpdateScript(Buffer.from(parts)); | ||
| expect(decoded).not.toBeNull(); | ||
| expect(decoded!.name.toString("utf8")).toBe("d/example"); | ||
| expect(decoded!.value.toString("utf8")).toBe("{}"); | ||
| }); | ||
|
|
||
| it("rejects garbage scripts", () => { | ||
| expect(parseNameUpdateScript(Buffer.alloc(0))).toBeNull(); | ||
| expect(parseNameUpdateScript(Buffer.from([0x76, 0xa9]))).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| // ----------------------------------------------------------------------------- | ||
| // Pinned defaults sanity | ||
| // ----------------------------------------------------------------------------- | ||
|
|
||
| describe("pinned defaults", () => { | ||
| it("ships a non-empty server list", () => { | ||
| expect(DEFAULT_ELECTRUMX_SERVERS.length).toBeGreaterThan(0); | ||
| for (const s of DEFAULT_ELECTRUMX_SERVERS) { | ||
| expect(s.host.length).toBeGreaterThan(0); | ||
| expect(s.portTcpTls).toBeGreaterThan(0); | ||
| } | ||
| }); | ||
|
|
||
| it("ships pinned PEM blocks", () => { | ||
| expect(PINNED_ELECTRUMX_CERTS.length).toBeGreaterThan(0); | ||
| for (const pem of PINNED_ELECTRUMX_CERTS) { | ||
| expect(pem.includes("BEGIN CERTIFICATE")).toBe(true); | ||
| expect(pem.includes("END CERTIFICATE")).toBe(true); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| // ----------------------------------------------------------------------------- | ||
| // resolveNamecoinNIP05 — pure-function behaviour without network | ||
| // ----------------------------------------------------------------------------- | ||
|
|
||
| describe("resolveNamecoinNIP05 (no network)", () => { | ||
| it("returns null for non-Namecoin identifiers", async () => { | ||
| const r = await resolveNamecoinNIP05("alice@example.com"); | ||
| expect(r).toBeNull(); | ||
| }); | ||
|
|
||
| it("returns null when no servers are configured", async () => { | ||
| const r = await resolveNamecoinNIP05("alice@example.bit", { servers: [] }); | ||
| expect(r).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| // ----------------------------------------------------------------------------- | ||
| // Optional integration suite — guarded behind an env flag. | ||
| // ----------------------------------------------------------------------------- | ||
|
|
||
| const integration = process.env.NOSTRCHECK_NAMECOIN_INTEGRATION === "1"; | ||
|
|
||
| describe.skipIf(!integration)("resolveNamecoinNIP05 — live ElectrumX (integration)", () => { | ||
| it("resolves a known .bit identity", async () => { | ||
| // Set NOSTRCHECK_NAMECOIN_INTEGRATION=1 and provide your own | ||
| // identifier here when running the integration suite locally. | ||
| const id = process.env.NOSTRCHECK_NAMECOIN_TEST_ID ?? "d/test"; | ||
| const r = await resolveNamecoinNIP05(id, { connectTimeoutMs: 8000, readTimeoutMs: 12000 }); | ||
| // We don't assert success — the public network may be offline; the | ||
| // suite is here to provide a manual smoke check, not a guarantee. | ||
| expect(r === null || typeof r === "object").toBe(true); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With
order: 2, this plugin runs afteractiveNIP05, butexecutePluginsstops at the firstfalseresult andactiveNIP05returnsfalsefor non-registered NIP-05 values (including.bit). In the common configuration where both plugins are enabled, valid Namecoin identifiers never reach this resolver and are always rejected, which contradicts the documented “composes with activeNIP05” behavior.Useful? React with 👍 / 👎.