From c133be0589bf3a2d295e814afefc872716407f67 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 25 May 2026 15:10:10 -0600 Subject: [PATCH] feat(kez,kez-chat/web): nostr verifier checks profile, posts, AND kind-30078 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- kez-chat/web/package-lock.json | 164 +++++++++++++++++++++- kez-chat/web/package.json | 1 + kez-chat/web/src/lib/verifiers/nostr.ts | 173 ++++++++++++++++++++++-- kez-chat/web/src/routes/AddClaim.svelte | 15 +- rust/crates/kez-channels/src/nostr.rs | 110 ++++++++++++++- rust/crates/kez-channels/tests/nostr.rs | 64 ++++++++- 6 files changed, 498 insertions(+), 29 deletions(-) diff --git a/kez-chat/web/package-lock.json b/kez-chat/web/package-lock.json index 68c338d..76d80ae 100644 --- a/kez-chat/web/package-lock.json +++ b/kez-chat/web/package-lock.json @@ -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", diff --git a/kez-chat/web/package.json b/kez-chat/web/package.json index 32d5ea8..8a3e652 100644 --- a/kez-chat/web/package.json +++ b/kez-chat/web/package.json @@ -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": { diff --git a/kez-chat/web/src/lib/verifiers/nostr.ts b/kez-chat/web/src/lib/verifiers/nostr.ts index 9993259..ef4e545 100644 --- a/kez-chat/web/src/lib/verifiers/nostr.ts +++ b/kez-chat/web/src/lib/verifiers/nostr.ts @@ -1,16 +1,167 @@ -// Nostr channel verifier — v0.2. +// Nostr channel verifier. // -// Verifying a `nostr:` 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:` 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 { + // 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'.`, + }, ); }; diff --git a/kez-chat/web/src/routes/AddClaim.svelte b/kez-chat/web/src/routes/AddClaim.svelte index 0939de1..f927393 100644 --- a/kez-chat/web/src/routes/AddClaim.svelte +++ b/kez-chat/web/src/routes/AddClaim.svelte @@ -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", diff --git a/rust/crates/kez-channels/src/nostr.rs b/rust/crates/kez-channels/src/nostr.rs index e00330a..d41a6a2 100644 --- a/rust/crates/kez-channels/src/nostr.rs +++ b/rust/crates/kez-channels/src/nostr.rs @@ -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 == []` 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 { + 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 { diff --git a/rust/crates/kez-channels/tests/nostr.rs b/rust/crates/kez-channels/tests/nostr.rs index 0071ceb..78ea2df 100644 --- a/rust/crates/kez-channels/tests/nostr.rs +++ b/rust/crates/kez-channels/tests/nostr.rs @@ -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();