feat(kez,kez-chat/web): nostr verifier checks profile, posts, AND kind-30078
Most users won't manually craft a NIP-78 kind-30078 event with a d=kez
tag — that needed a nostr client most folks don't have. So verifiers
now look in all three sensible spots and the user picks whichever is
easiest to publish:
1. Kind 0 (profile metadata) — kez fence in the `about` field
2. Kind 1 (text note) — kez fence in the post body
3. Kind 30078 (NIP-78) — envelope as event content (advanced)
Web (kez-chat/web):
• New verifier implementation (replaces the v0.1 stub). Adds nostr-
tools (~108 KB) under dynamic import so it lands in its own chunk
— initial JS only grew 128→130 KB.
• SimplePool.querySync against five public relays (Damus, nos.lol,
primal, snort, nostr.wine), 4s timeout, kinds [0,1,30078] in one
REQ. Returns ✓ on first match, with an evidence_url to njump.me.
• AddClaim instructions for nostr rewritten — "pick whichever is
easiest" with concrete steps for each.
Rust (kez-channels):
• Filter now includes kinds [0, 1, 30078], limit bumped to 200.
• extract_proof_body() pulls the right candidate out of each event:
- kind 0 → JSON-decode content, return `about`
- kind 1 / 30078 → return content as-is
• 4 new unit tests (extract_proof_body for each kind incl. malformed
profile) + 2 new integration tests:
- verifies_proof_from_profile_about_field
- verifies_proof_from_kind_1_post
• Updated existing integration tests for the new filter shape.
All 11 unit + 7 integration nostr tests pass. Live at https://kez.lat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
21d9b705b7
commit
c133be0589
164
kez-chat/web/package-lock.json
generated
164
kez-chat/web/package-lock.json
generated
@ -14,6 +14,7 @@
|
||||
"@scure/base": "^1.1.9",
|
||||
"canonicalize": "^2.0.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"nostr-tools": "^2.23.5",
|
||||
"svelte-spa-router": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -475,6 +476,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
||||
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||
@ -900,6 +913,90 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
|
||||
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/curves": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@scure/base": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
||||
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39/node_modules/@scure/base": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/acorn-typescript": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
|
||||
@ -1858,6 +1955,71 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools": {
|
||||
"version": "2.23.5",
|
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.5.tgz",
|
||||
"integrity": "sha512-Fa7ZlUdjfUW1P4E7H3yBexhOHYi18XNyvd2n7eNHkYR085xADX6Y8V8Vm7nT/XQajaFOBrptXmVIGkJ2E4vfVw==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "2.1.1",
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0",
|
||||
"@scure/bip32": "2.0.1",
|
||||
"@scure/bip39": "2.0.1",
|
||||
"nostr-wasm": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@noble/curves": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@scure/base": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-wasm": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
|
||||
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@ -2081,7 +2243,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
"@scure/base": "^1.1.9",
|
||||
"canonicalize": "^2.0.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"nostr-tools": "^2.23.5",
|
||||
"svelte-spa-router": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -1,16 +1,167 @@
|
||||
// Nostr channel verifier — v0.2.
|
||||
// Nostr channel verifier.
|
||||
//
|
||||
// Verifying a `nostr:<npub>` claim means connecting to a pool of relays
|
||||
// over WebSocket, subscribing to kind-30078 events tagged d='kez' from
|
||||
// that pubkey, and checking the event's content is a valid kez envelope.
|
||||
// That's a chunky dependency (relay list, ws pool, NIP-19 decode) so we
|
||||
// defer it; the user can still create the claim and copy the artifact.
|
||||
// A claim with subject `nostr:<npub>` can be proven three ways — we
|
||||
// check all of them in parallel:
|
||||
//
|
||||
// 1. Kind 0 (profile metadata) — kez fence in the `about` field
|
||||
// 2. Kind 1 (text note) — kez fence in the post body
|
||||
// 3. Kind 30078 (app data, NIP-78) — envelope JSON as event content
|
||||
//
|
||||
// We open a pool of public relays via WebSocket, subscribe to those
|
||||
// kinds for the user's pubkey, and look for a kez envelope in each
|
||||
// event's content. nostr-tools handles relay protocol + NIP-19 decode.
|
||||
//
|
||||
// The library is dynamically imported so its ~30 KB lands in its own
|
||||
// chunk and only loads when a nostr claim gets verified.
|
||||
|
||||
import { skipped, type Verifier } from "./types.js";
|
||||
import { parseAnyEnvelope, verifyEnvelope } from "../kez.js";
|
||||
import { fail, ok, type Verifier, type VerifyResult } from "./types.js";
|
||||
|
||||
export const verifyNostr: Verifier = async () => {
|
||||
return skipped(
|
||||
"Nostr verification is coming in v0.2",
|
||||
"Need a relay pool + NIP-19 decoder. You can still publish the artifact manually.",
|
||||
/**
|
||||
* Public relays we ask. Chosen for: (a) reliable browser WS, (b) broad
|
||||
* coverage of who-publishes-where, (c) permissive read policy (no auth
|
||||
* required for these kinds). Adding more relays slows us down without
|
||||
* helping much — the same events get redelivered.
|
||||
*/
|
||||
const RELAYS = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.snort.social",
|
||||
"wss://nostr.wine",
|
||||
];
|
||||
|
||||
/** How long to wait for events before giving up. */
|
||||
const TIMEOUT_MS = 4_000;
|
||||
|
||||
interface NostrEvent {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
kind: number;
|
||||
content: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
async function tryCandidate(
|
||||
ev: NostrEvent,
|
||||
ctx: { subject: string; primary: string },
|
||||
): Promise<VerifyResult | null> {
|
||||
// For kind 0 the content is JSON; the kez fence lives in `about`.
|
||||
let body = ev.content;
|
||||
if (ev.kind === 0) {
|
||||
try {
|
||||
const meta = JSON.parse(ev.content) as { about?: string };
|
||||
body = meta.about ?? "";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!body) return null;
|
||||
let fetched;
|
||||
try {
|
||||
fetched = await parseAnyEnvelope(body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (fetched.payload.subject !== ctx.subject) return null;
|
||||
const evidence_url = `https://njump.me/${ev.id}`;
|
||||
const kindLabel =
|
||||
ev.kind === 0 ? "profile bio" : ev.kind === 1 ? "post" : `kind-${ev.kind} event`;
|
||||
if (fetched.payload.primary !== ctx.primary) {
|
||||
return fail(
|
||||
`${kindLabel} envelope's primary (${fetched.payload.primary ?? "missing"}) doesn't match your key (${ctx.primary}).`,
|
||||
{ evidence_url, fetched },
|
||||
);
|
||||
}
|
||||
if (!verifyEnvelope(fetched)) {
|
||||
return fail(`${kindLabel} envelope signature is invalid`, {
|
||||
evidence_url,
|
||||
fetched,
|
||||
});
|
||||
}
|
||||
return ok(`Verified via nostr ${kindLabel}`, { evidence_url, fetched });
|
||||
}
|
||||
|
||||
export const verifyNostr: Verifier = async (ctx) => {
|
||||
if (!ctx.subject.startsWith("nostr:")) {
|
||||
return fail(`Nostr verifier got non-nostr subject: ${ctx.subject}`);
|
||||
}
|
||||
const raw = ctx.subject.slice("nostr:".length);
|
||||
|
||||
// nostr-tools accepts hex pubkeys directly; for npub we decode.
|
||||
let pubkeyHex: string;
|
||||
let nip19;
|
||||
try {
|
||||
({ nip19 } = await import("nostr-tools"));
|
||||
} catch (e) {
|
||||
return fail(`Failed to load nostr-tools: ${(e as Error).message}`);
|
||||
}
|
||||
if (raw.startsWith("npub1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(raw);
|
||||
if (decoded.type !== "npub") {
|
||||
return fail(`Expected npub, got ${decoded.type}`);
|
||||
}
|
||||
pubkeyHex = decoded.data;
|
||||
} catch (e) {
|
||||
return fail(`Couldn't decode ${raw}: ${(e as Error).message}`);
|
||||
}
|
||||
} else if (/^[0-9a-f]{64}$/i.test(raw)) {
|
||||
pubkeyHex = raw.toLowerCase();
|
||||
} else {
|
||||
return fail(
|
||||
`Subject ${ctx.subject} is neither an npub1… nor a 64-char hex pubkey`,
|
||||
);
|
||||
}
|
||||
|
||||
const { SimplePool } = await import("nostr-tools");
|
||||
const pool = new SimplePool();
|
||||
let events: NostrEvent[];
|
||||
try {
|
||||
events = (await pool.querySync(
|
||||
RELAYS,
|
||||
{ authors: [pubkeyHex], kinds: [0, 1, 30078], limit: 200 },
|
||||
{ maxWait: TIMEOUT_MS },
|
||||
)) as unknown as NostrEvent[];
|
||||
} catch (e) {
|
||||
return fail(`Relay query failed: ${(e as Error).message}`);
|
||||
} finally {
|
||||
pool.close(RELAYS);
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return fail(
|
||||
`No events found from ${pubkeyHex.slice(0, 12)}… on ${RELAYS.length} relays`,
|
||||
{
|
||||
details:
|
||||
`Queried kinds 0 (profile), 1 (posts), 30078 (app data).\n` +
|
||||
`Relays: ${RELAYS.join(", ")}.\n` +
|
||||
`If you publish to other relays, the proof may be there but invisible to us.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Sort newest-first so a recently-updated proof wins.
|
||||
events.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
let firstFailure: VerifyResult | null = null;
|
||||
for (const ev of events) {
|
||||
const result = await tryCandidate(ev, ctx);
|
||||
if (!result) continue;
|
||||
if (result.status === "ok") return result;
|
||||
// Remember the first concrete failure (primary mismatch or bad sig)
|
||||
// so if nothing else matches we can show it.
|
||||
if (!firstFailure) firstFailure = result;
|
||||
}
|
||||
|
||||
if (firstFailure) return firstFailure;
|
||||
|
||||
return fail(
|
||||
`Found ${events.length} event(s) from ${pubkeyHex.slice(0, 12)}…, but none contain a kez envelope for ${ctx.subject}`,
|
||||
{
|
||||
details:
|
||||
`Add the kez fence to your profile bio, post it as a normal note, ` +
|
||||
`or publish a kind-30078 event with d='kez'.`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@ -91,12 +91,17 @@
|
||||
const raw = s.trim();
|
||||
return raw.startsWith("nostr:") ? raw : `nostr:${raw}`;
|
||||
},
|
||||
preferredFormat: "json",
|
||||
preferredFormat: "markdown",
|
||||
instructions: () =>
|
||||
`1. Publish a nostr event of kind 30078 to relays where you're active.\n` +
|
||||
`2. The event 'content' is the JSON envelope on the right.\n` +
|
||||
`3. Tag with 'd':'kez' so verifiers can find it deterministically.\n\n` +
|
||||
`A KEZ nostr extension (future) will do this in one click. For v0.1, use a nostr client that lets you craft a kind-30078 event by hand.`,
|
||||
`Pick whichever is easiest — verifiers check all three:\n\n` +
|
||||
`A. Add the markdown block on the right to your nostr profile bio\n` +
|
||||
` (the "About" field on Damus, primal.net, etc.). One-time edit,\n` +
|
||||
` anyone fetching your profile sees the proof.\n\n` +
|
||||
`B. Post the markdown block as a normal nostr note (kind 1).\n` +
|
||||
` Easiest if you're already active — just paste and publish.\n\n` +
|
||||
`C. (Advanced) Publish a NIP-78 kind-30078 event with d='kez' and\n` +
|
||||
` the JSON envelope as the event 'content'. Cleanest for tooling\n` +
|
||||
` but needs a client that exposes kind-30078 creation.`,
|
||||
},
|
||||
{
|
||||
key: "bluesky",
|
||||
|
||||
@ -2,10 +2,16 @@
|
||||
//! the event content is a KEZ proof for the requested `nostr:npub1...`
|
||||
//! identity.
|
||||
//!
|
||||
//! Spec §5: KEZ proofs on nostr are published as kind `30078`
|
||||
//! (parameterized replaceable) events. We query a relay for events with
|
||||
//! `authors == [<hex pubkey>]` and `kinds == [30078]`, then run each
|
||||
//! event's `content` through the standard proof parser.
|
||||
//! Spec §5: KEZ proofs on nostr can live in three places — we check all
|
||||
//! three because most users will pick the easiest one:
|
||||
//!
|
||||
//! • kind 0 — profile metadata; the proof lives in the `about` field
|
||||
//! • kind 1 — a normal text note containing the fenced kez block
|
||||
//! • kind 30078 — NIP-78 app data; canonical for tooling, content is the
|
||||
//! raw envelope (JSON or compact)
|
||||
//!
|
||||
//! We REQ all three kinds for the author's pubkey in one filter, then try
|
||||
//! `parse_and_verify_for` on each candidate. First match wins.
|
||||
//!
|
||||
//! Trust model for this minimal cut: a malicious relay could forge events,
|
||||
//! but the embedded KEZ proof carries its own signature over the primary
|
||||
@ -28,6 +34,11 @@ use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use crate::{Channel, ChannelError, ChannelHit, ChannelResult, parse_and_verify_for};
|
||||
|
||||
pub const KEZ_NOSTR_KIND: u32 = 30078;
|
||||
pub const NOSTR_KIND_PROFILE: u32 = 0;
|
||||
pub const NOSTR_KIND_NOTE: u32 = 1;
|
||||
/// Kinds we look at when verifying — checked in this order (newest first
|
||||
/// within each kind, all kinds merged together).
|
||||
const VERIFY_KINDS: &[u32] = &[NOSTR_KIND_PROFILE, NOSTR_KIND_NOTE, KEZ_NOSTR_KIND];
|
||||
const DEFAULT_RELAYS: &[&str] = &[
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
@ -172,8 +183,8 @@ impl Channel for NostrChannel {
|
||||
let pubkey_hex = nostr_pubkey_hex(identity).map_err(|e| ChannelError::Other(e.into()))?;
|
||||
let filter = NostrFilter {
|
||||
authors: vec![pubkey_hex.clone()],
|
||||
kinds: vec![KEZ_NOSTR_KIND],
|
||||
limit: Some(20),
|
||||
kinds: VERIFY_KINDS.to_vec(),
|
||||
limit: Some(200),
|
||||
};
|
||||
let events = self.fetcher.fetch_events(&filter).await?;
|
||||
|
||||
@ -182,7 +193,14 @@ impl Channel for NostrChannel {
|
||||
if !event_matches_author(&event, &pubkey_hex) {
|
||||
continue;
|
||||
}
|
||||
match parse_and_verify_for(&event.content, identity) {
|
||||
// For kind 0 the content is JSON metadata; the fence lives in
|
||||
// `about` (which a verifier must JSON-decode so the escaped
|
||||
// newlines in the markdown fence become real newlines). For
|
||||
// everything else the content IS the candidate body.
|
||||
let Some(body) = extract_proof_body(&event) else {
|
||||
continue;
|
||||
};
|
||||
match parse_and_verify_for(&body, identity) {
|
||||
Ok(hit) => return Ok(hit),
|
||||
Err(err) => last_error = Some(err),
|
||||
}
|
||||
@ -191,6 +209,25 @@ impl Channel for NostrChannel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure: pull the bytes we should hand to `parse_and_verify_for` out of
|
||||
/// a nostr event. Returns None if the event doesn't carry a candidate
|
||||
/// (e.g. a kind-0 with no `about`, or unrecognized kind).
|
||||
///
|
||||
/// We return `String` (not `&str`) because kind-0 forces us to JSON-decode
|
||||
/// the `about` field — the resulting bytes don't live in `event.content`.
|
||||
pub fn extract_proof_body(event: &NostrEvent) -> Option<String> {
|
||||
match event.kind {
|
||||
NOSTR_KIND_PROFILE => {
|
||||
// Profile metadata is JSON; the kez fence (if any) lives in
|
||||
// `about` AFTER JSON-decoding (escaped newlines become real).
|
||||
let parsed: Value = serde_json::from_str(&event.content).ok()?;
|
||||
parsed.get("about")?.as_str().map(|s| s.to_owned())
|
||||
}
|
||||
NOSTR_KIND_NOTE | KEZ_NOSTR_KIND => Some(event.content.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build and sign a NIP-01 event. The event id is `sha256` of the
|
||||
/// canonically-serialized array `[0, pubkey, created_at, kind, tags,
|
||||
/// content]`; the signature is Schnorr over that id.
|
||||
@ -436,6 +473,65 @@ mod tests {
|
||||
assert_eq!(event.id, expected_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_proof_body_pulls_about_from_kind_0() {
|
||||
let ev = NostrEvent {
|
||||
id: "x".into(),
|
||||
pubkey: "a".repeat(64),
|
||||
created_at: 0,
|
||||
kind: 0,
|
||||
tags: vec![],
|
||||
// Note the escaped \n inside the JSON — extract_proof_body must
|
||||
// decode them so parse_proof sees a real markdown fence.
|
||||
content: r#"{"name":"Jason","about":"hello\n\n```kez\n{\"sig\":1}\n```"}"#.into(),
|
||||
sig: String::new(),
|
||||
};
|
||||
let body = extract_proof_body(&ev).expect("about should be extracted");
|
||||
assert!(body.contains("```kez\n"), "expected real newline in fence, got: {body:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_proof_body_passes_kind_1_through() {
|
||||
let ev = NostrEvent {
|
||||
id: "x".into(),
|
||||
pubkey: "a".repeat(64),
|
||||
created_at: 0,
|
||||
kind: 1,
|
||||
tags: vec![],
|
||||
content: "kez:z1:abc".into(),
|
||||
sig: String::new(),
|
||||
};
|
||||
assert_eq!(extract_proof_body(&ev).unwrap(), "kez:z1:abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_proof_body_skips_unknown_kinds() {
|
||||
let ev = NostrEvent {
|
||||
id: "x".into(),
|
||||
pubkey: "a".repeat(64),
|
||||
created_at: 0,
|
||||
kind: 9999,
|
||||
tags: vec![],
|
||||
content: "kez:z1:abc".into(),
|
||||
sig: String::new(),
|
||||
};
|
||||
assert!(extract_proof_body(&ev).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_proof_body_skips_kind_0_without_about() {
|
||||
let ev = NostrEvent {
|
||||
id: "x".into(),
|
||||
pubkey: "a".repeat(64),
|
||||
created_at: 0,
|
||||
kind: 0,
|
||||
tags: vec![],
|
||||
content: r#"{"name":"someone"}"#.into(),
|
||||
sig: String::new(),
|
||||
};
|
||||
assert!(extract_proof_body(&ev).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_matches_author_is_case_insensitive() {
|
||||
let ev = NostrEvent {
|
||||
|
||||
@ -6,7 +6,10 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use kez_channels::nostr::{KEZ_NOSTR_KIND, NostrChannel, NostrEvent, NostrFetcher, NostrFilter};
|
||||
use kez_channels::nostr::{
|
||||
KEZ_NOSTR_KIND, NOSTR_KIND_NOTE, NOSTR_KIND_PROFILE, NostrChannel, NostrEvent, NostrFetcher,
|
||||
NostrFilter,
|
||||
};
|
||||
use kez_channels::{Channel, ChannelError, ChannelResult};
|
||||
use kez_core::{ClaimPayload, Identity, NostrSecret, SignedClaim, nostr_pubkey_hex};
|
||||
|
||||
@ -66,7 +69,7 @@ async fn verifies_self_published_proof_from_relay() {
|
||||
let fetcher = CapturingFetcher {
|
||||
events: vec![make_event(&pubkey_hex, compact)],
|
||||
expected_authors: vec![pubkey_hex.clone()],
|
||||
expected_kinds: vec![KEZ_NOSTR_KIND],
|
||||
expected_kinds: vec![NOSTR_KIND_PROFILE, NOSTR_KIND_NOTE, KEZ_NOSTR_KIND],
|
||||
};
|
||||
|
||||
let channel = NostrChannel::with_fetcher(Arc::new(fetcher));
|
||||
@ -87,7 +90,7 @@ async fn skips_events_whose_pubkey_field_mismatches() {
|
||||
let fetcher = CapturingFetcher {
|
||||
events: vec![make_event(&pubkey_b_hex, compact_a)],
|
||||
expected_authors: vec![nostr_pubkey_hex(&identity_a).unwrap()],
|
||||
expected_kinds: vec![KEZ_NOSTR_KIND],
|
||||
expected_kinds: vec![NOSTR_KIND_PROFILE, NOSTR_KIND_NOTE, KEZ_NOSTR_KIND],
|
||||
};
|
||||
|
||||
let channel = NostrChannel::with_fetcher(Arc::new(fetcher));
|
||||
@ -117,7 +120,7 @@ async fn rejects_proof_signed_for_different_subject() {
|
||||
let fetcher = CapturingFetcher {
|
||||
events: vec![make_event(&pubkey_a_hex, compact)],
|
||||
expected_authors: vec![pubkey_a_hex.clone()],
|
||||
expected_kinds: vec![KEZ_NOSTR_KIND],
|
||||
expected_kinds: vec![NOSTR_KIND_PROFILE, NOSTR_KIND_NOTE, KEZ_NOSTR_KIND],
|
||||
};
|
||||
|
||||
let channel = NostrChannel::with_fetcher(Arc::new(fetcher));
|
||||
@ -134,13 +137,64 @@ async fn no_events_yields_not_found() {
|
||||
let fetcher = CapturingFetcher {
|
||||
events: vec![],
|
||||
expected_authors: vec![nostr_pubkey_hex(&identity).unwrap()],
|
||||
expected_kinds: vec![KEZ_NOSTR_KIND],
|
||||
expected_kinds: vec![NOSTR_KIND_PROFILE, NOSTR_KIND_NOTE, KEZ_NOSTR_KIND],
|
||||
};
|
||||
let channel = NostrChannel::with_fetcher(Arc::new(fetcher));
|
||||
let err = channel.fetch_and_verify(&identity).await.unwrap_err();
|
||||
assert!(matches!(err, ChannelError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verifies_proof_from_profile_about_field() {
|
||||
// Most realistic UX: the proof lives inside the `about` field of a
|
||||
// standard nostr profile event (kind 0). We must JSON-decode the
|
||||
// content, pull `about`, and feed that to the parser.
|
||||
let (_secret, identity, signed) = sign_for_self();
|
||||
let pubkey_hex = nostr_pubkey_hex(&identity).unwrap();
|
||||
let compact = signed.to_compact().unwrap();
|
||||
let about = format!("hey, I'm here. proof: {compact}");
|
||||
let profile_json = serde_json::to_string(&serde_json::json!({
|
||||
"name": "tester",
|
||||
"about": about,
|
||||
"picture": "https://example.com/p.png",
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let mut ev = make_event(&pubkey_hex, profile_json);
|
||||
ev.kind = NOSTR_KIND_PROFILE;
|
||||
ev.tags = vec![]; // kind-0 doesn't carry the d tag
|
||||
|
||||
let fetcher = CapturingFetcher {
|
||||
events: vec![ev],
|
||||
expected_authors: vec![pubkey_hex],
|
||||
expected_kinds: vec![NOSTR_KIND_PROFILE, NOSTR_KIND_NOTE, KEZ_NOSTR_KIND],
|
||||
};
|
||||
let channel = NostrChannel::with_fetcher(Arc::new(fetcher));
|
||||
let hit = channel.fetch_and_verify(&identity).await.unwrap();
|
||||
assert_eq!(hit.proof, signed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verifies_proof_from_kind_1_post() {
|
||||
// The user pasted the markdown block as a normal nostr post.
|
||||
let (_secret, identity, signed) = sign_for_self();
|
||||
let pubkey_hex = nostr_pubkey_hex(&identity).unwrap();
|
||||
let markdown = signed.to_markdown_proof().unwrap();
|
||||
|
||||
let mut ev = make_event(&pubkey_hex, markdown);
|
||||
ev.kind = NOSTR_KIND_NOTE;
|
||||
ev.tags = vec![];
|
||||
|
||||
let fetcher = CapturingFetcher {
|
||||
events: vec![ev],
|
||||
expected_authors: vec![pubkey_hex],
|
||||
expected_kinds: vec![NOSTR_KIND_PROFILE, NOSTR_KIND_NOTE, KEZ_NOSTR_KIND],
|
||||
};
|
||||
let channel = NostrChannel::with_fetcher(Arc::new(fetcher));
|
||||
let hit = channel.fetch_and_verify(&identity).await.unwrap();
|
||||
assert_eq!(hit.proof, signed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetcher_failure_surfaces_as_unreachable() {
|
||||
let (_s, identity, _signed) = sign_for_self();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user