import { describe, expect, it } from "vitest"; import { Identity, NostrSecret, newClaimPayload, nostrPubkeyHex, signClaim, toCompact, toMarkdown, } from "@kez/core"; import { KEZ_NOSTR_KIND, NOSTR_KIND_NOTE, NOSTR_KIND_PROFILE, VERIFY_KINDS, NostrChannel, type NostrEvent, type NostrFetcher, type NostrFilter, buildReqMessage, buildSignedEvent, eventMatchesAuthor, extractProofBody, parseRelayMessage, } from "../src/nostr.js"; import { sha256 } from "@noble/hashes/sha2"; import { bytesToHex } from "@noble/hashes/utils"; class CapturingFetcher implements NostrFetcher { constructor( private events: NostrEvent[], private expectedAuthors: string[], private expectedKinds: number[], ) {} async fetchEvents(filter: NostrFilter): Promise { expect(filter.authors).toEqual(this.expectedAuthors); expect(filter.kinds).toEqual(this.expectedKinds); return this.events; } } function makeEvent( pubkeyHex: string, content: string, kind: number = KEZ_NOSTR_KIND, ): NostrEvent { return { id: "0".repeat(64), pubkey: pubkeyHex, created_at: Math.floor(Date.now() / 1000), kind, tags: [["d", "kez"]], content, sig: "f".repeat(128), }; } function signForSelf() { const secret = NostrSecret.generate(); const identity = secret.identity(); const signed = signClaim(newClaimPayload(identity, identity, new Date()), secret); return { secret, identity, signed }; } describe("NostrChannel", () => { it("verifies a self-published proof", async () => { const { identity, signed } = signForSelf(); const pubkey = nostrPubkeyHex(identity); const fetcher = new CapturingFetcher( [makeEvent(pubkey, toCompact(signed))], [pubkey], VERIFY_KINDS, ); const channel = new NostrChannel(fetcher); const hit = await channel.fetchAndVerify(identity); expect(hit.proof).toEqual(signed); }); it("skips events whose pubkey field mismatches", async () => { const a = signForSelf(); const b = signForSelf(); const pubkeyB = nostrPubkeyHex(b.identity); const compactA = toCompact(a.signed); const fetcher = new CapturingFetcher( [makeEvent(pubkeyB, compactA)], [nostrPubkeyHex(a.identity)], VERIFY_KINDS, ); const channel = new NostrChannel(fetcher); await expect(channel.fetchAndVerify(a.identity)).rejects.toMatchObject({ kind: "NotFound", }); }); it("rejects proof signed for a different subject", async () => { const a = signForSelf(); const b = signForSelf(); // a signs a claim with subject = b const claimForB = signClaim( newClaimPayload(b.identity, a.identity, new Date()), a.secret, ); const fetcher = new CapturingFetcher( [makeEvent(nostrPubkeyHex(a.identity), toCompact(claimForB))], [nostrPubkeyHex(a.identity)], VERIFY_KINDS, ); const channel = new NostrChannel(fetcher); await expect(channel.fetchAndVerify(a.identity)).rejects.toMatchObject({ kind: "SubjectMismatch", }); }); it("no events yields NotFound", async () => { const { identity } = signForSelf(); const fetcher = new CapturingFetcher( [], [nostrPubkeyHex(identity)], VERIFY_KINDS, ); const channel = new NostrChannel(fetcher); await expect(channel.fetchAndVerify(identity)).rejects.toMatchObject({ kind: "NotFound", }); }); }); describe("NostrChannel — kinds 1 and 0", () => { it("verifies a Markdown proof inside a kind-1 note", async () => { const { identity, signed } = signForSelf(); const pubkey = nostrPubkeyHex(identity); const note = `Verifying my KEZ identity.\n\n${toMarkdown(signed)}\n#kez`; const fetcher = new CapturingFetcher( [makeEvent(pubkey, note, NOSTR_KIND_NOTE)], [pubkey], VERIFY_KINDS, ); const hit = await new NostrChannel(fetcher).fetchAndVerify(identity); expect(hit.proof).toEqual(signed); }); it("verifies a proof in a kind-0 profile `about`", async () => { const { identity, signed } = signForSelf(); const pubkey = nostrPubkeyHex(identity); const profile = JSON.stringify({ name: "me", about: toMarkdown(signed) }); const fetcher = new CapturingFetcher( [makeEvent(pubkey, profile, NOSTR_KIND_PROFILE)], [pubkey], VERIFY_KINDS, ); const hit = await new NostrChannel(fetcher).fetchAndVerify(identity); expect(hit.proof).toEqual(signed); }); }); describe("extractProofBody", () => { const pk = "a".repeat(64); it("pulls and JSON-decodes `about` from kind 0", () => { const ev = makeEvent(pk, JSON.stringify({ about: "line1\nline2" }), 0); expect(extractProofBody(ev)).toBe("line1\nline2"); }); it("passes kind 1 and 30078 content through", () => { expect(extractProofBody(makeEvent(pk, "kez:z1:abc", 1))).toBe("kez:z1:abc"); expect(extractProofBody(makeEvent(pk, "kez:z1:abc", KEZ_NOSTR_KIND))).toBe("kez:z1:abc"); }); it("skips kind 0 without `about`, bad JSON, and unknown kinds", () => { expect(extractProofBody(makeEvent(pk, JSON.stringify({ name: "x" }), 0))).toBeUndefined(); expect(extractProofBody(makeEvent(pk, "not json", 0))).toBeUndefined(); expect(extractProofBody(makeEvent(pk, "kez:z1:abc", 7))).toBeUndefined(); }); }); describe("nostr wire helpers", () => { it("buildReqMessage includes filter fields", () => { const req = buildReqMessage("sub-1", { authors: ["aa"], kinds: [30078], limit: 20 }); expect(JSON.parse(req)).toEqual([ "REQ", "sub-1", { authors: ["aa"], kinds: [30078], limit: 20 }, ]); }); it("buildReqMessage omits limit when undefined", () => { const req = buildReqMessage("s", { authors: ["aa"], kinds: [1] }); expect(JSON.parse(req)[2]).toEqual({ authors: ["aa"], kinds: [1] }); }); it("parseRelayMessage handles EVENT / EOSE / other", () => { const ev = JSON.stringify([ "EVENT", "s", { id: "0".repeat(64), pubkey: "a".repeat(64), created_at: 0, kind: 30078, tags: [], content: "x", sig: "f".repeat(128), }, ]); expect(parseRelayMessage(ev).kind).toBe("event"); expect(parseRelayMessage(JSON.stringify(["EOSE", "s"])).kind).toBe("eose"); expect(parseRelayMessage(JSON.stringify(["NOTICE", "hi"])).kind).toBe("other"); expect(parseRelayMessage("not json").kind).toBe("other"); }); it("buildSignedEvent produces valid NIP-01 event", () => { const signer = NostrSecret.generate(); const event = buildSignedEvent( signer, 1_700_000_000, KEZ_NOSTR_KIND, [["d", "kez-sigchain"]], "hello", ); expect(event.id).toHaveLength(64); expect(event.pubkey).toHaveLength(64); expect(event.sig).toHaveLength(128); expect(event.pubkey).toBe(signer.pubkeyHex()); // id must equal sha256(canonical [0, pubkey, created_at, kind, tags, content]) const canonical = JSON.stringify([ 0, event.pubkey, event.created_at, event.kind, event.tags, event.content, ]); const expected = bytesToHex(sha256(new TextEncoder().encode(canonical))); expect(event.id).toBe(expected); }); it("eventMatchesAuthor is case-insensitive", () => { const ev = makeEvent("ABCDEF", ""); expect(eventMatchesAuthor(ev, "abcdef")).toBe(true); expect(eventMatchesAuthor(ev, "ababab")).toBe(false); }); });