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:
Jason Tudisco 2026-05-25 15:10:10 -06:00
parent 21d9b705b7
commit c133be0589
6 changed files with 498 additions and 29 deletions

View File

@ -14,6 +14,7 @@
"@scure/base": "^1.1.9", "@scure/base": "^1.1.9",
"canonicalize": "^2.0.0", "canonicalize": "^2.0.0",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"nostr-tools": "^2.23.5",
"svelte-spa-router": "^4.0.1" "svelte-spa-router": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
@ -475,6 +476,18 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@noble/curves": {
"version": "1.9.7", "version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
@ -900,6 +913,90 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", "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": "^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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2081,7 +2243,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",

View File

@ -16,6 +16,7 @@
"@scure/base": "^1.1.9", "@scure/base": "^1.1.9",
"canonicalize": "^2.0.0", "canonicalize": "^2.0.0",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"nostr-tools": "^2.23.5",
"svelte-spa-router": "^4.0.1" "svelte-spa-router": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,16 +1,167 @@
// Nostr channel verifier — v0.2. // Nostr channel verifier.
// //
// Verifying a `nostr:<npub>` claim means connecting to a pool of relays // A claim with subject `nostr:<npub>` can be proven three ways — we
// over WebSocket, subscribing to kind-30078 events tagged d='kez' from // check all of them in parallel:
// 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 // 1. Kind 0 (profile metadata) — kez fence in the `about` field
// defer it; the user can still create the claim and copy the artifact. // 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( * Public relays we ask. Chosen for: (a) reliable browser WS, (b) broad
"Nostr verification is coming in v0.2", * coverage of who-publishes-where, (c) permissive read policy (no auth
"Need a relay pool + NIP-19 decoder. You can still publish the artifact manually.", * 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'.`,
},
); );
}; };

View File

@ -91,12 +91,17 @@
const raw = s.trim(); const raw = s.trim();
return raw.startsWith("nostr:") ? raw : `nostr:${raw}`; return raw.startsWith("nostr:") ? raw : `nostr:${raw}`;
}, },
preferredFormat: "json", preferredFormat: "markdown",
instructions: () => instructions: () =>
`1. Publish a nostr event of kind 30078 to relays where you're active.\n` + `Pick whichever is easiest — verifiers check all three:\n\n` +
`2. The event 'content' is the JSON envelope on the right.\n` + `A. Add the markdown block on the right to your nostr profile bio\n` +
`3. Tag with 'd':'kez' so verifiers can find it deterministically.\n\n` + ` (the "About" field on Damus, primal.net, etc.). One-time edit,\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.`, ` 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", key: "bluesky",

View File

@ -2,10 +2,16 @@
//! the event content is a KEZ proof for the requested `nostr:npub1...` //! the event content is a KEZ proof for the requested `nostr:npub1...`
//! identity. //! identity.
//! //!
//! Spec §5: KEZ proofs on nostr are published as kind `30078` //! Spec §5: KEZ proofs on nostr can live in three places — we check all
//! (parameterized replaceable) events. We query a relay for events with //! three because most users will pick the easiest one:
//! `authors == [<hex pubkey>]` and `kinds == [30078]`, then run each //!
//! event's `content` through the standard proof parser. //! • 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, //! Trust model for this minimal cut: a malicious relay could forge events,
//! but the embedded KEZ proof carries its own signature over the primary //! 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}; use crate::{Channel, ChannelError, ChannelHit, ChannelResult, parse_and_verify_for};
pub const KEZ_NOSTR_KIND: u32 = 30078; 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] = &[ const DEFAULT_RELAYS: &[&str] = &[
"wss://relay.damus.io", "wss://relay.damus.io",
"wss://nos.lol", "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 pubkey_hex = nostr_pubkey_hex(identity).map_err(|e| ChannelError::Other(e.into()))?;
let filter = NostrFilter { let filter = NostrFilter {
authors: vec![pubkey_hex.clone()], authors: vec![pubkey_hex.clone()],
kinds: vec![KEZ_NOSTR_KIND], kinds: VERIFY_KINDS.to_vec(),
limit: Some(20), limit: Some(200),
}; };
let events = self.fetcher.fetch_events(&filter).await?; let events = self.fetcher.fetch_events(&filter).await?;
@ -182,7 +193,14 @@ impl Channel for NostrChannel {
if !event_matches_author(&event, &pubkey_hex) { if !event_matches_author(&event, &pubkey_hex) {
continue; 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), Ok(hit) => return Ok(hit),
Err(err) => last_error = Some(err), 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 /// Build and sign a NIP-01 event. The event id is `sha256` of the
/// canonically-serialized array `[0, pubkey, created_at, kind, tags, /// canonically-serialized array `[0, pubkey, created_at, kind, tags,
/// content]`; the signature is Schnorr over that id. /// content]`; the signature is Schnorr over that id.
@ -436,6 +473,65 @@ mod tests {
assert_eq!(event.id, expected_id); 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] #[test]
fn event_matches_author_is_case_insensitive() { fn event_matches_author_is_case_insensitive() {
let ev = NostrEvent { let ev = NostrEvent {

View File

@ -6,7 +6,10 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; 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_channels::{Channel, ChannelError, ChannelResult};
use kez_core::{ClaimPayload, Identity, NostrSecret, SignedClaim, nostr_pubkey_hex}; 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 { let fetcher = CapturingFetcher {
events: vec![make_event(&pubkey_hex, compact)], events: vec![make_event(&pubkey_hex, compact)],
expected_authors: vec![pubkey_hex.clone()], 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)); let channel = NostrChannel::with_fetcher(Arc::new(fetcher));
@ -87,7 +90,7 @@ async fn skips_events_whose_pubkey_field_mismatches() {
let fetcher = CapturingFetcher { let fetcher = CapturingFetcher {
events: vec![make_event(&pubkey_b_hex, compact_a)], events: vec![make_event(&pubkey_b_hex, compact_a)],
expected_authors: vec![nostr_pubkey_hex(&identity_a).unwrap()], 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)); let channel = NostrChannel::with_fetcher(Arc::new(fetcher));
@ -117,7 +120,7 @@ async fn rejects_proof_signed_for_different_subject() {
let fetcher = CapturingFetcher { let fetcher = CapturingFetcher {
events: vec![make_event(&pubkey_a_hex, compact)], events: vec![make_event(&pubkey_a_hex, compact)],
expected_authors: vec![pubkey_a_hex.clone()], 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)); let channel = NostrChannel::with_fetcher(Arc::new(fetcher));
@ -134,13 +137,64 @@ async fn no_events_yields_not_found() {
let fetcher = CapturingFetcher { let fetcher = CapturingFetcher {
events: vec![], events: vec![],
expected_authors: vec![nostr_pubkey_hex(&identity).unwrap()], 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 channel = NostrChannel::with_fetcher(Arc::new(fetcher));
let err = channel.fetch_and_verify(&identity).await.unwrap_err(); let err = channel.fetch_and_verify(&identity).await.unwrap_err();
assert!(matches!(err, ChannelError::NotFound(_))); 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] #[tokio::test]
async fn fetcher_failure_surfaces_as_unreachable() { async fn fetcher_failure_surfaces_as_unreachable() {
let (_s, identity, _signed) = sign_for_self(); let (_s, identity, _signed) = sign_for_self();