Jason Tudisco b237ac2d9e docs+fix: SPEC v0.4, Node nostr channel checks kinds 0/1/30078, NOSTR-CHAT.md current
SPEC.md → v0.4
- §3.1 BIP-39 recovery phrases (12-word hashed with "kez-bip39-12-v1",
  24-word = seed), parsing rules, test vectors.
- §7 nostr: kind 30078 canonical (MUST); verifiers SHOULD also check
  kind-1 notes and the kind-0 `about`.
- §9.1 sigchain HTTP store API; §11 BIP-39 primitive; §15 Python and
  kez-chat; §16 applications (kez-chat tags); changelog.

nodejs kez-channels
- NostrChannel now requests kinds 0, 1, 30078 and extracts the proof
  body per kind (kind-0 `about` JSON-decoded), mirroring Rust's
  extract_proof_body. Proofs posted as kind-1 notes (e.g. via NIP-07
  from kez-chat) previously failed to verify in Node.
- Tests for kind-1 Markdown notes, kind-0 about, and extractProofBody.

Docs
- kez-chat/web/NOSTR-CHAT.md rewritten for the current protocol: `q`
  routing tag, kinds 4242/4244/10142/0/10002, KEZ-signed heartbeats,
  v1–v4 envelopes, receipts, relay ordering, honest metadata model.
- Stale `#h` comments in nostr-id.ts / nostr-transport.ts.
- rust/README.md nostr channel rows list all three kinds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:00:51 -06:00

241 lines
7.3 KiB
TypeScript

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<NostrEvent[]> {
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);
});
});