import { describe, expect, it } from "vitest"; import { Identity, NostrSecret, newClaimPayload, nostrPubkeyHex, signClaim, toCompact, } from "@kez/core"; import { KEZ_NOSTR_KIND, NostrChannel, type NostrEvent, type NostrFetcher, type NostrFilter, buildReqMessage, buildSignedEvent, eventMatchesAuthor, 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): NostrEvent { return { id: "0".repeat(64), pubkey: pubkeyHex, created_at: Math.floor(Date.now() / 1000), kind: KEZ_NOSTR_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], [KEZ_NOSTR_KIND], ); 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)], [KEZ_NOSTR_KIND], ); 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)], [KEZ_NOSTR_KIND], ); 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)], [KEZ_NOSTR_KIND], ); const channel = new NostrChannel(fetcher); await expect(channel.fetchAndVerify(identity)).rejects.toMatchObject({ kind: "NotFound", }); }); }); 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); }); });