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>
241 lines
7.3 KiB
TypeScript
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);
|
|
});
|
|
});
|