From e2531fef7bf4530f2e9079d81e5119a16cd53537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sat, 13 Jun 2026 15:43:53 -0400 Subject: [PATCH 1/4] Add search/since/until/p-e-d tag filters, --pow, NIP-42 --auth, and sign-only mode --- build.zig.zon | 6 +-- src/main.zig | 134 +++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 125 insertions(+), 15 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 8af44e8..d120c98 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,12 +1,12 @@ .{ .name = .noz, - .version = "0.0.0", + .version = "0.1.0", .fingerprint = 0xefbdd0725217fb6e, .minimum_zig_version = "0.16.0", .dependencies = .{ .nostr = .{ - .url = "https://github.com/privkeyio/libnostr-z/archive/refs/tags/v0.3.4.tar.gz", - .hash = "nostr-0.3.4-JY6OcH66DwA7h9hyimyKIhCNQsL010avarOpuObjQfRe", + .url = "https://github.com/privkeyio/libnostr-z/archive/213f754d5a34a36459dab6a7dd37713184a8643f.tar.gz", + .hash = "nostr-0.3.4-JY6OcJC-DwC6zvA2L0zRpxCwrau93V5xj9URtK86ZH57", }, }, .paths = .{ diff --git a/src/main.zig b/src/main.zig index cab37c3..78a3837 100644 --- a/src/main.zig +++ b/src/main.zig @@ -17,9 +17,9 @@ const usage = \\ relay Print the NIP-11 relay information document \\ sync [filters..] NIP-77 reconcile src's events into dst \\ - \\event flags: --sec -c -k --ts - \\ -t (repeatable) -p (repeatable) - \\req/count flags: -k -a -i -l -t + \\event flags: --sec -c -k --ts --pow --auth + \\ -t -p -e -d (all repeatable) (no url = sign only) + \\req/count flags: -k -a -i -l -t -p/-e/-d -s -u --search \\ \\The secret for key/event can also be set via NOSTR_SECRET_KEY instead of --sec/. \\ @@ -110,6 +110,8 @@ fn cmdEvent(env_sec: ?[]const u8, arena: Allocator, out: *Io.Writer, args: []con var content: []const u8 = ""; var kind: i32 = 1; var ts: ?i64 = null; + var pow: ?u8 = null; + var do_auth = false; var url: ?[]const u8 = null; var tags: std.ArrayListUnmanaged([]const []const u8) = .empty; @@ -138,12 +140,25 @@ fn cmdEvent(env_sec: ?[]const u8, arena: Allocator, out: *Io.Writer, args: []con i += 1; const pk = next(args, i) orelse return missing(out, "-p"); try tags.append(arena, try arena.dupe([]const u8, &.{ "p", pk })); + } else if (std.mem.eql(u8, a, "-e")) { + i += 1; + const id = next(args, i) orelse return missing(out, "-e"); + try tags.append(arena, try arena.dupe([]const u8, &.{ "e", id })); + } else if (std.mem.eql(u8, a, "-d")) { + i += 1; + const d = next(args, i) orelse return missing(out, "-d"); + try tags.append(arena, try arena.dupe([]const u8, &.{ "d", d })); + } else if (std.mem.eql(u8, a, "--pow")) { + i += 1; + const v = next(args, i) orelse return missing(out, "--pow"); + pow = std.fmt.parseInt(u8, v, 10) catch return invalid(out, "pow difficulty", v); + } else if (std.mem.eql(u8, a, "--auth")) { + do_auth = true; } else { url = a; } } - const relay_url = url orelse return missing(out, "relay url"); const sec_str = sec orelse env_sec orelse return missing(out, "--sec"); try nostr.init(); @@ -162,11 +177,21 @@ fn cmdEvent(env_sec: ?[]const u8, arena: Allocator, out: *Io.Writer, args: []con _ = builder.setContent(content); _ = builder.setCreatedAt(ts orelse nostr.io.timestamp()); if (tags.items.len > 0) _ = builder.setTags(tags.items); - try builder.sign(&kp); + if (pow) |p| { + _ = builder.mine(&kp, p) catch return out.print("pow mining failed\n", .{}); + } else { + try builder.sign(&kp); + } var ev_buf: [65536]u8 = undefined; const ev_json = try builder.serialize(&ev_buf); + // No relay URL: sign-only. Print the event (e.g. to build a NIP-98 header). + const relay_url = url orelse { + try out.print("{s}\n", .{ev_json}); + return; + }; + var relay = try nostr.relay.Relay.init(arena, relay_url, .{ .read_timeout_ms = read_timeout_ms }); defer relay.deinit(); try relay.connect(); @@ -174,17 +199,68 @@ fn cmdEvent(env_sec: ?[]const u8, arena: Allocator, out: *Io.Writer, args: []con var event = try nostr.Event.parse(ev_json); defer event.deinit(); - try relay.publish(&event); - // Wait for the OK before exiting so the publish actually lands. + const ok_success = publishWithAuth(&relay, &event, relay_url, &kp, do_auth); + + try out.print("{s}\n", .{ev_json}); + std.debug.print("{s}\n", .{if (ok_success) "success" else "rejected"}); +} + +// Publish, handling NIP-42 when do_auth: an auth-required relay sends the +// challenge proactively, while an open relay sends it only after a protected +// event is rejected. Either way: authenticate when a challenge appears, then +// re-publish once. Returns the relay's final OK success flag. +fn publishWithAuth(relay: *nostr.relay.Relay, event: *const nostr.Event, url: []const u8, kp: *const nostr.Keypair, do_auth: bool) bool { + relay.publish(event) catch return false; + + var authed = false; + var republished = false; var tries: usize = 0; - while (tries < 100) : (tries += 1) { - var msg = (try relay.receive()) orelse break; + while (tries < 200) : (tries += 1) { + var msg = (relay.receive() catch return false) orelse return false; defer msg.deinit(); - if (msg.msg_type == .ok) break; + switch (msg.msg_type) { + .auth => { + if (do_auth and !authed) { + if (msg.subscription_id) |challenge| { + sendAuth(relay, url, kp, challenge) catch {}; + authed = true; + } + } + }, + .ok => { + if (msg.success) return true; + if (authed and !republished) { + relay.publish(event) catch return false; + republished = true; + } else { + return false; + } + }, + else => {}, + } } + return false; +} - try out.print("{s}\n", .{ev_json}); +// NIP-42: sign and send a kind-22242 auth event binding the challenge and URL. +fn sendAuth(relay: *nostr.relay.Relay, url: []const u8, kp: *const nostr.Keypair, challenge: []const u8) !void { + var b = nostr.EventBuilder{}; + _ = b.setKind(22242); + _ = b.setContent(""); + _ = b.setCreatedAt(nostr.io.timestamp()); + const auth_tags = [_][]const []const u8{ + &.{ "relay", url }, + &.{ "challenge", challenge }, + }; + _ = b.setTags(&auth_tags); + try b.sign(kp); + + var buf: [4096]u8 = undefined; + const ev_json = try b.serialize(&buf); + var ev = try nostr.Event.parse(ev_json); + defer ev.deinit(); + try relay.authenticate(&ev); } const Query = struct { url: []const u8, filter: nostr.Filter }; @@ -194,6 +270,9 @@ const Query = struct { url: []const u8, filter: nostr.Filter }; fn parseQuery(arena: Allocator, out: *Io.Writer, args: []const [:0]const u8) !?Query { var url: ?[]const u8 = null; var limit: i32 = 0; + var since: i64 = 0; + var until: i64 = 0; + var search: ?[]const u8 = null; var kinds: std.ArrayListUnmanaged(i32) = .empty; var authors: std.ArrayListUnmanaged([32]u8) = .empty; var ids: std.ArrayListUnmanaged([32]u8) = .empty; @@ -228,6 +307,29 @@ fn parseQuery(arena: Allocator, out: *Io.Writer, args: []const [:0]const u8) !?Q const v = next(args, i) orelse return missingNull(out, "-t"); const tf = tagFilterFromSpec(arena, v) catch return invalidNull(out, "tag", v); try tag_filters.append(arena, tf); + } else if (std.mem.eql(u8, a, "-p")) { + i += 1; + const v = next(args, i) orelse return missingNull(out, "-p"); + try tag_filters.append(arena, try singleTagFilter(arena, 'p', v)); + } else if (std.mem.eql(u8, a, "-e")) { + i += 1; + const v = next(args, i) orelse return missingNull(out, "-e"); + try tag_filters.append(arena, try singleTagFilter(arena, 'e', v)); + } else if (std.mem.eql(u8, a, "-d")) { + i += 1; + const v = next(args, i) orelse return missingNull(out, "-d"); + try tag_filters.append(arena, try singleTagFilter(arena, 'd', v)); + } else if (std.mem.eql(u8, a, "-s")) { + i += 1; + const v = next(args, i) orelse return missingNull(out, "-s"); + since = std.fmt.parseInt(i64, v, 10) catch return invalidNull(out, "since", v); + } else if (std.mem.eql(u8, a, "-u")) { + i += 1; + const v = next(args, i) orelse return missingNull(out, "-u"); + until = std.fmt.parseInt(i64, v, 10) catch return invalidNull(out, "until", v); + } else if (std.mem.eql(u8, a, "--search")) { + i += 1; + search = next(args, i) orelse return missingNull(out, "--search"); } else { url = a; } @@ -246,6 +348,9 @@ fn parseQuery(arena: Allocator, out: *Io.Writer, args: []const [:0]const u8) !?Q .authors_bytes = if (authors.items.len > 0) authors.items else null, .ids_bytes = if (ids.items.len > 0) ids.items else null, .limit_val = limit, + .since_val = since, + .until_val = until, + .search_str = search, .tag_filters = if (tag_filters.items.len > 0) tag_filters.items else null, }, }; @@ -322,9 +427,14 @@ fn tagFilterFromSpec(arena: Allocator, spec: []const u8) !nostr.FilterTagEntry { const name = spec[0..eq]; if (name.len != 1 or !std.ascii.isAlphabetic(name[0])) return error.InvalidTagFilter; const value = if (eq < spec.len) spec[eq + 1 ..] else ""; + return singleTagFilter(arena, name[0], value); +} + +// A # tag filter with a single string value (e.g. -p/-e/-d). +fn singleTagFilter(arena: Allocator, letter: u8, value: []const u8) !nostr.FilterTagEntry { const values = try arena.alloc(nostr.TagValue, 1); values[0] = .{ .string = value }; - return .{ .letter = name[0], .values = values }; + return .{ .letter = letter, .values = values }; } fn cmdRelay(io: Io, arena: Allocator, out: *Io.Writer, args: []const [:0]const u8) !void { From bb03dd9e1a59069326a0b214a365fc568b31a20d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sat, 13 Jun 2026 15:51:45 -0400 Subject: [PATCH 2/4] Pin libnostr-z to v0.3.5 --- build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index d120c98..5575cea 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.16.0", .dependencies = .{ .nostr = .{ - .url = "https://github.com/privkeyio/libnostr-z/archive/213f754d5a34a36459dab6a7dd37713184a8643f.tar.gz", - .hash = "nostr-0.3.4-JY6OcJC-DwC6zvA2L0zRpxCwrau93V5xj9URtK86ZH57", + .url = "https://github.com/privkeyio/libnostr-z/archive/refs/tags/v0.3.5.tar.gz", + .hash = "nostr-0.3.5-JY6OcKzDDwBHnc3_SJVIflzFJNzTqjxebWZsNB6u4BdW", }, }, .paths = .{ From dbf7259e4e48e5e2d9cc8682c13e71f5ec65c42b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sat, 13 Jun 2026 16:05:35 -0400 Subject: [PATCH 3/4] Fix NIP-42 publish auth ordering and cap --pow difficulty --- src/main.zig | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/main.zig b/src/main.zig index 78a3837..8e17473 100644 --- a/src/main.zig +++ b/src/main.zig @@ -152,6 +152,7 @@ fn cmdEvent(env_sec: ?[]const u8, arena: Allocator, out: *Io.Writer, args: []con i += 1; const v = next(args, i) orelse return missing(out, "--pow"); pow = std.fmt.parseInt(u8, v, 10) catch return invalid(out, "pow difficulty", v); + if (pow.? > 32) return invalid(out, "pow difficulty", v); } else if (std.mem.eql(u8, a, "--auth")) { do_auth = true; } else { @@ -213,8 +214,12 @@ fn cmdEvent(env_sec: ?[]const u8, arena: Allocator, out: *Io.Writer, args: []con fn publishWithAuth(relay: *nostr.relay.Relay, event: *const nostr.Event, url: []const u8, kp: *const nostr.Keypair, do_auth: bool) bool { relay.publish(event) catch return false; + var id_hex: [65]u8 = undefined; + event.idHex(&id_hex); + var authed = false; var republished = false; + var saw_reject = false; var tries: usize = 0; while (tries < 200) : (tries += 1) { var msg = (relay.receive() catch return false) orelse return false; @@ -223,16 +228,36 @@ fn publishWithAuth(relay: *nostr.relay.Relay, event: *const nostr.Event, url: [] .auth => { if (do_auth and !authed) { if (msg.subscription_id) |challenge| { - sendAuth(relay, url, kp, challenge) catch {}; + // Only mark authed once the auth event is actually sent; a + // protected publish cannot succeed without it. + sendAuth(relay, url, kp, challenge) catch return false; authed = true; + // Open relay: the protected event was already rejected, so + // nothing else will prompt a retry. Re-publish now. + if (saw_reject and !republished) { + relay.publish(event) catch return false; + republished = true; + } } } }, .ok => { + // For OK, subscription_id holds the event id. Ignore OKs for other + // events (e.g. the relay's ack of our kind-22242 auth event). + if (msg.subscription_id) |ok_id| { + if (!std.mem.eql(u8, ok_id, id_hex[0..64])) continue; + } if (msg.success) return true; + saw_reject = true; if (authed and !republished) { + // Auth-required relay: the challenge came first, so this is the + // unauthenticated publish being rejected. Retry now authed. relay.publish(event) catch return false; republished = true; + } else if (do_auth and !authed and authRequired(msg.message)) { + // Open relay: an auth-required rejection is followed by an AUTH + // challenge, so keep waiting for it instead of giving up. + continue; } else { return false; } @@ -243,6 +268,13 @@ fn publishWithAuth(relay: *nostr.relay.Relay, event: *const nostr.Event, url: [] return false; } +// A NIP-42 rejection carries the "auth-required:" reason prefix; treat a missing +// reason as auth-required too, since --auth signals the relay is expected to gate. +fn authRequired(message: ?[]const u8) bool { + const m = message orelse return true; + return std.mem.startsWith(u8, m, "auth-required:"); +} + // NIP-42: sign and send a kind-22242 auth event binding the challenge and URL. fn sendAuth(relay: *nostr.relay.Relay, url: []const u8, kp: *const nostr.Keypair, challenge: []const u8) !void { var b = nostr.EventBuilder{}; From 81e7b8afb4687ed2cdcc43d209579e413da60032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sat, 13 Jun 2026 16:09:16 -0400 Subject: [PATCH 4/4] Bump version to 0.2.0 --- build.zig.zon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.zig.zon b/build.zig.zon index 5575cea..7746980 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .noz, - .version = "0.1.0", + .version = "0.2.0", .fingerprint = 0xefbdd0725217fb6e, .minimum_zig_version = "0.16.0", .dependencies = .{