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>
550 lines
18 KiB
Rust
550 lines
18 KiB
Rust
//! Nostr channel: fetches events from one or more relays and verifies that
|
|
//! the event content is a KEZ proof for the requested `nostr:npub1...`
|
|
//! identity.
|
|
//!
|
|
//! 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
|
|
//! key. As long as the proof's `primary == subject` (the npub case), the
|
|
//! relay cannot mint a valid proof without the user's private key. Event
|
|
//! signature verification is TODO for the cross-key case (e.g. an ed25519
|
|
//! primary claiming a nostr identity).
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use kez_core::{Identity, NostrSecret, nostr_pubkey_hex};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
use sha2::{Digest, Sha256};
|
|
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",
|
|
"wss://relay.primal.net",
|
|
];
|
|
const FETCH_TIMEOUT: Duration = Duration::from_secs(8);
|
|
|
|
/// A nostr event in the wire shape we care about (a subset of NIP-01).
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct NostrEvent {
|
|
pub id: String,
|
|
pub pubkey: String,
|
|
pub created_at: i64,
|
|
pub kind: u32,
|
|
#[serde(default)]
|
|
pub tags: Vec<Vec<String>>,
|
|
pub content: String,
|
|
pub sig: String,
|
|
}
|
|
|
|
/// Filter sent in a nostr REQ message.
|
|
#[derive(Debug, Clone)]
|
|
pub struct NostrFilter {
|
|
pub authors: Vec<String>, // lowercase hex pubkeys
|
|
pub kinds: Vec<u32>,
|
|
pub limit: Option<u32>,
|
|
}
|
|
|
|
/// Fetcher abstraction so tests can substitute canned events without
|
|
/// touching the network.
|
|
#[async_trait]
|
|
pub trait NostrFetcher: Send + Sync {
|
|
async fn fetch_events(&self, filter: &NostrFilter) -> ChannelResult<Vec<NostrEvent>>;
|
|
}
|
|
|
|
/// Real fetcher: queries each relay in turn (websocket), merges events,
|
|
/// and times out if relays are unresponsive.
|
|
pub struct RelayPoolFetcher {
|
|
relays: Vec<String>,
|
|
}
|
|
|
|
impl RelayPoolFetcher {
|
|
pub fn new(relays: Vec<String>) -> Self {
|
|
Self { relays }
|
|
}
|
|
pub fn defaults() -> Self {
|
|
Self::new(DEFAULT_RELAYS.iter().map(|s| (*s).to_owned()).collect())
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl NostrFetcher for RelayPoolFetcher {
|
|
async fn fetch_events(&self, filter: &NostrFilter) -> ChannelResult<Vec<NostrEvent>> {
|
|
let mut last_error: Option<ChannelError> = None;
|
|
let mut events: Vec<NostrEvent> = Vec::new();
|
|
for relay in &self.relays {
|
|
match query_relay(relay, filter).await {
|
|
Ok(mut batch) => events.append(&mut batch),
|
|
Err(err) => last_error = Some(err),
|
|
}
|
|
// First relay that returns anything is enough for a discovery hit;
|
|
// we keep going only if we still have nothing.
|
|
if !events.is_empty() {
|
|
break;
|
|
}
|
|
}
|
|
if events.is_empty()
|
|
&& let Some(err) = last_error
|
|
{
|
|
return Err(err);
|
|
}
|
|
Ok(events)
|
|
}
|
|
}
|
|
|
|
async fn query_relay(url: &str, filter: &NostrFilter) -> ChannelResult<Vec<NostrEvent>> {
|
|
let (mut ws, _) = connect_async(url)
|
|
.await
|
|
.map_err(|e| ChannelError::Unreachable(format!("connect {url}: {e}")))?;
|
|
|
|
let sub_id = "kez-1";
|
|
let req = build_req_message(sub_id, filter);
|
|
ws.send(Message::Text(req.into()))
|
|
.await
|
|
.map_err(|e| ChannelError::Unreachable(format!("send REQ {url}: {e}")))?;
|
|
|
|
let mut events = Vec::new();
|
|
loop {
|
|
let next = tokio::time::timeout(FETCH_TIMEOUT, ws.next()).await;
|
|
let Ok(Some(msg)) = next else { break };
|
|
let msg = match msg {
|
|
Ok(m) => m,
|
|
Err(e) => return Err(ChannelError::Unreachable(format!("ws read {url}: {e}"))),
|
|
};
|
|
let Message::Text(text) = msg else { continue };
|
|
match parse_relay_message(&text) {
|
|
RelayMessage::Event(ev) => events.push(ev),
|
|
RelayMessage::EndOfStored => break,
|
|
RelayMessage::Other => continue,
|
|
}
|
|
}
|
|
|
|
let _ = ws
|
|
.send(Message::Text(
|
|
json!(["CLOSE", sub_id]).to_string().into(),
|
|
))
|
|
.await;
|
|
let _ = ws.close(None).await;
|
|
|
|
Ok(events)
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct NostrChannel {
|
|
fetcher: Arc<dyn NostrFetcher>,
|
|
}
|
|
|
|
impl NostrChannel {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
fetcher: Arc::new(RelayPoolFetcher::defaults()),
|
|
}
|
|
}
|
|
pub fn with_fetcher(fetcher: Arc<dyn NostrFetcher>) -> Self {
|
|
Self { fetcher }
|
|
}
|
|
}
|
|
|
|
impl Default for NostrChannel {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Channel for NostrChannel {
|
|
fn system(&self) -> &'static str {
|
|
"nostr"
|
|
}
|
|
|
|
async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult<ChannelHit> {
|
|
let pubkey_hex = nostr_pubkey_hex(identity).map_err(|e| ChannelError::Other(e.into()))?;
|
|
let filter = NostrFilter {
|
|
authors: vec![pubkey_hex.clone()],
|
|
kinds: VERIFY_KINDS.to_vec(),
|
|
limit: Some(200),
|
|
};
|
|
let events = self.fetcher.fetch_events(&filter).await?;
|
|
|
|
let mut last_error: Option<ChannelError> = None;
|
|
for event in events {
|
|
if !event_matches_author(&event, &pubkey_hex) {
|
|
continue;
|
|
}
|
|
// 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),
|
|
}
|
|
}
|
|
Err(last_error.unwrap_or_else(|| ChannelError::NotFound(identity.clone())))
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
pub fn build_signed_event(
|
|
signer: &NostrSecret,
|
|
created_at: i64,
|
|
kind: u32,
|
|
tags: Vec<Vec<String>>,
|
|
content: String,
|
|
) -> ChannelResult<NostrEvent> {
|
|
let pubkey_hex = signer.pubkey_hex();
|
|
let canonical = json!([0, pubkey_hex, created_at, kind, tags, content]);
|
|
let canonical_str = serde_json::to_string(&canonical)
|
|
.map_err(|e| ChannelError::Other(anyhow::anyhow!("event serialize: {e}")))?;
|
|
let digest: [u8; 32] = Sha256::digest(canonical_str.as_bytes()).into();
|
|
let id_hex = hex::encode(digest);
|
|
let sig = signer
|
|
.sign_raw(&digest)
|
|
.map_err(|e| ChannelError::Other(anyhow::anyhow!("schnorr sign: {e}")))?;
|
|
Ok(NostrEvent {
|
|
id: id_hex,
|
|
pubkey: pubkey_hex,
|
|
created_at,
|
|
kind,
|
|
tags,
|
|
content,
|
|
sig: hex::encode(sig),
|
|
})
|
|
}
|
|
|
|
/// Publish one event to a single relay over WebSocket. Returns Ok if the
|
|
/// relay either acknowledges with `["OK", id, true, ...]` or closes the
|
|
/// connection without rejecting.
|
|
pub async fn publish_event_to_relay(
|
|
relay_url: &str,
|
|
event: &NostrEvent,
|
|
) -> ChannelResult<()> {
|
|
let (mut ws, _) = connect_async(relay_url)
|
|
.await
|
|
.map_err(|e| ChannelError::Unreachable(format!("connect {relay_url}: {e}")))?;
|
|
|
|
let msg = json!(["EVENT", event]).to_string();
|
|
ws.send(Message::Text(msg.into()))
|
|
.await
|
|
.map_err(|e| ChannelError::Unreachable(format!("send EVENT {relay_url}: {e}")))?;
|
|
|
|
// Wait briefly for an OK / NOTICE response. Don't hang forever if the
|
|
// relay never sends one — many relays accept and stay silent.
|
|
let deadline = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
|
while let Some(msg) = ws.next().await {
|
|
let Ok(Message::Text(text)) = msg else { continue };
|
|
let Ok(arr) = serde_json::from_str::<Value>(&text) else {
|
|
continue;
|
|
};
|
|
let Some(arr) = arr.as_array() else { continue };
|
|
match arr.first().and_then(|v| v.as_str()) {
|
|
Some("OK") => {
|
|
// ["OK", <event-id>, <accepted: bool>, <message>]
|
|
if arr.get(2).and_then(|v| v.as_bool()) == Some(false) {
|
|
let reason = arr.get(3).and_then(|v| v.as_str()).unwrap_or("");
|
|
return Err(ChannelError::Other(anyhow::anyhow!(
|
|
"relay {relay_url} rejected event: {reason}"
|
|
)));
|
|
}
|
|
return Ok(());
|
|
}
|
|
Some("NOTICE") => {
|
|
// Informational; not failure on its own. Keep reading.
|
|
continue;
|
|
}
|
|
_ => continue,
|
|
}
|
|
}
|
|
Ok(())
|
|
})
|
|
.await;
|
|
|
|
let _ = ws.close(None).await;
|
|
match deadline {
|
|
Ok(result) => result,
|
|
Err(_) => Ok(()), // Timeout — assume accepted; we'll retry by GET later.
|
|
}
|
|
}
|
|
|
|
/// Pure: build the JSON REQ message a nostr relay expects.
|
|
pub fn build_req_message(sub_id: &str, filter: &NostrFilter) -> String {
|
|
let mut spec = serde_json::Map::new();
|
|
spec.insert("authors".into(), json!(filter.authors));
|
|
spec.insert("kinds".into(), json!(filter.kinds));
|
|
if let Some(limit) = filter.limit {
|
|
spec.insert("limit".into(), json!(limit));
|
|
}
|
|
json!(["REQ", sub_id, Value::Object(spec)]).to_string()
|
|
}
|
|
|
|
/// Pure: defense against a relay that lies about authorship in the array
|
|
/// envelope but sets a different `pubkey` inside the event JSON.
|
|
pub fn event_matches_author(event: &NostrEvent, expected_hex: &str) -> bool {
|
|
event.pubkey.eq_ignore_ascii_case(expected_hex)
|
|
}
|
|
|
|
/// Parsed shape of a single relay → client message.
|
|
pub enum RelayMessage {
|
|
Event(NostrEvent),
|
|
EndOfStored,
|
|
Other,
|
|
}
|
|
|
|
/// Pure: parse one inbound `["EVENT", sub, {…}]` / `["EOSE", sub]` / other
|
|
/// frame into our enum.
|
|
pub fn parse_relay_message(text: &str) -> RelayMessage {
|
|
let Ok(value) = serde_json::from_str::<Value>(text) else {
|
|
return RelayMessage::Other;
|
|
};
|
|
let Some(arr) = value.as_array() else {
|
|
return RelayMessage::Other;
|
|
};
|
|
match arr.first().and_then(|v| v.as_str()) {
|
|
Some("EVENT") => {
|
|
let Some(ev_val) = arr.get(2) else {
|
|
return RelayMessage::Other;
|
|
};
|
|
match serde_json::from_value::<NostrEvent>(ev_val.clone()) {
|
|
Ok(ev) => RelayMessage::Event(ev),
|
|
Err(_) => RelayMessage::Other,
|
|
}
|
|
}
|
|
Some("EOSE") => RelayMessage::EndOfStored,
|
|
_ => RelayMessage::Other,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn build_req_includes_filter_fields() {
|
|
let filter = NostrFilter {
|
|
authors: vec!["aa".into()],
|
|
kinds: vec![30078],
|
|
limit: Some(20),
|
|
};
|
|
let req = build_req_message("sub-1", &filter);
|
|
let parsed: Value = serde_json::from_str(&req).unwrap();
|
|
assert_eq!(parsed[0], "REQ");
|
|
assert_eq!(parsed[1], "sub-1");
|
|
assert_eq!(parsed[2]["authors"], json!(["aa"]));
|
|
assert_eq!(parsed[2]["kinds"], json!([30078]));
|
|
assert_eq!(parsed[2]["limit"], json!(20));
|
|
}
|
|
|
|
#[test]
|
|
fn build_req_omits_limit_when_none() {
|
|
let filter = NostrFilter {
|
|
authors: vec!["aa".into()],
|
|
kinds: vec![1],
|
|
limit: None,
|
|
};
|
|
let req = build_req_message("s", &filter);
|
|
let parsed: Value = serde_json::from_str(&req).unwrap();
|
|
assert!(parsed[2].get("limit").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_event_message() {
|
|
let frame = json!([
|
|
"EVENT",
|
|
"sub-1",
|
|
{
|
|
"id": "0".repeat(64),
|
|
"pubkey": "a".repeat(64),
|
|
"created_at": 1700000000_i64,
|
|
"kind": 30078,
|
|
"tags": [["d", "kez"]],
|
|
"content": "hello",
|
|
"sig": "f".repeat(128),
|
|
}
|
|
])
|
|
.to_string();
|
|
match parse_relay_message(&frame) {
|
|
RelayMessage::Event(ev) => {
|
|
assert_eq!(ev.kind, 30078);
|
|
assert_eq!(ev.content, "hello");
|
|
assert_eq!(ev.tags, vec![vec!["d".to_owned(), "kez".to_owned()]]);
|
|
}
|
|
_ => panic!("expected Event"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_eose_message() {
|
|
let frame = json!(["EOSE", "sub-1"]).to_string();
|
|
assert!(matches!(
|
|
parse_relay_message(&frame),
|
|
RelayMessage::EndOfStored
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_garbage_message_is_other() {
|
|
assert!(matches!(parse_relay_message("not json"), RelayMessage::Other));
|
|
assert!(matches!(parse_relay_message("{}"), RelayMessage::Other));
|
|
assert!(matches!(
|
|
parse_relay_message(r#"["NOTICE","hi"]"#),
|
|
RelayMessage::Other
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn build_signed_event_produces_valid_nip01_event() {
|
|
let signer = kez_core::NostrSecret::generate();
|
|
let event = build_signed_event(
|
|
&signer,
|
|
1_700_000_000,
|
|
30078,
|
|
vec![vec!["d".into(), "kez-sigchain".into()]],
|
|
"hello".into(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Basic shape: 32-byte id, 32-byte pubkey, 64-byte sig (all hex).
|
|
assert_eq!(event.id.len(), 64);
|
|
assert_eq!(event.pubkey.len(), 64);
|
|
assert_eq!(event.sig.len(), 128);
|
|
assert_eq!(event.pubkey, signer.pubkey_hex());
|
|
assert_eq!(event.kind, 30078);
|
|
assert_eq!(event.content, "hello");
|
|
|
|
// The id MUST equal sha256 of the canonical serialization.
|
|
let canonical = serde_json::json!([
|
|
0,
|
|
event.pubkey,
|
|
event.created_at,
|
|
event.kind,
|
|
event.tags,
|
|
event.content
|
|
]);
|
|
let canonical_str = serde_json::to_string(&canonical).unwrap();
|
|
let expected_id =
|
|
hex::encode(<Sha256 as sha2::Digest>::digest(canonical_str.as_bytes()));
|
|
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 {
|
|
id: "x".into(),
|
|
pubkey: "ABCDEF".into(),
|
|
created_at: 0,
|
|
kind: 30078,
|
|
tags: vec![],
|
|
content: String::new(),
|
|
sig: String::new(),
|
|
};
|
|
assert!(event_matches_author(&ev, "abcdef"));
|
|
assert!(!event_matches_author(&ev, "ababab"));
|
|
}
|
|
}
|