From d523ad672a67c0be2ab9ca2c1d676f5e1889caba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Mon, 5 Oct 2026 23:37:20 -0400 Subject: [PATCH 1/2] Add a send command for raw client messages --- README.md | 3 +- src/main.zig | 81 ++++++++++++++++++++++++++++++++++++++ tests/integration_relay.sh | 6 +++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2d777cd..48e3c00 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,12 @@ noz req [filters..] Subscribe and print matching events (to EOSE) noz count [filters..] Count matching events (NIP-45) noz relay Print the NIP-11 relay info doc noz sync [..] NIP-77 reconcile src's events into dst +noz send Send one raw client message, print replies (--sec --auth for NIP-42) noz decode Decode a NIP-19 entity (npub/nsec/note/nevent/naddr/nprofile) noz verify [event-json] Verify an event's id and signature (reads stdin if no arg) ``` -The secret for `key`/`event` can also come from `NOSTR_SECRET_KEY` instead of +The secret for `key`/`event`/`send` can also come from `NOSTR_SECRET_KEY` instead of `--sec`/``, so it never lands in your shell history. ## Examples diff --git a/src/main.zig b/src/main.zig index 70eedaa..5211211 100644 --- a/src/main.zig +++ b/src/main.zig @@ -16,6 +16,8 @@ const usage = \\ count [filters..] Count matching events (NIP-45) \\ relay Print the NIP-11 relay information document \\ sync [filters..] NIP-77 reconcile src's events into dst + \\ send [--sec --auth] Send one raw client message, print replies + \\ until EOSE/CLOSED/COUNT/OK/NOTICE (--auth answers NIP-42) \\ decode Decode a NIP-19 entity (npub/nsec/note/nevent/naddr/nprofile) \\ verify [event-json] Verify an event's id and signature (reads stdin if no arg) \\ @@ -61,6 +63,8 @@ pub fn main(init: std.process.Init) !void { try cmdRelay(io, arena, out, rest); } else if (std.mem.eql(u8, cmd, "sync")) { try cmdSync(arena, out, rest); + } else if (std.mem.eql(u8, cmd, "send")) { + try cmdSend(env_sec, io, arena, out, rest); } else if (std.mem.eql(u8, cmd, "decode")) { try cmdDecode(arena, out, rest); } else if (std.mem.eql(u8, cmd, "verify")) { @@ -528,6 +532,83 @@ fn cmdReq(io: Io, arena: Allocator, out: *Io.Writer, args: []const [:0]const u8) } } +// Sends one raw client message and prints every relay reply verbatim until the +// reply that ends it (EOSE, CLOSED, COUNT, OK, or NOTICE). With --auth, a NIP-42 +// challenge is answered and the message re-sent once authenticated, and the +// auth-required rejection that preceded it is not printed. +fn cmdSend(env_sec: ?[]const u8, io: Io, arena: Allocator, out: *Io.Writer, args: []const [:0]const u8) !void { + var url: ?[]const u8 = null; + var json: ?[]const u8 = null; + var sec: ?[]const u8 = env_sec; + var do_auth = false; + var i: usize = 0; + while (i < args.len) : (i += 1) { + const a = args[i]; + if (std.mem.eql(u8, a, "--sec")) { + i += 1; + sec = next(args, i) orelse return missing(out, "--sec value"); + } else if (std.mem.eql(u8, a, "--auth")) { + do_auth = true; + } else if (url == null) { + url = a; + } else if (json == null) { + json = a; + } else return invalid(out, "argument", a); + } + const relay_url = url orelse return missing(out, "relay url"); + const msg_json = json orelse return missing(out, "message json"); + + try nostr.init(); + defer nostr.cleanup(); + + var kp = nostr.Keypair{ .secret_key = undefined, .public_key = undefined }; + defer kp.deinit(); + if (do_auth) { + const sec_str = sec orelse return missing(out, "--sec (required with --auth)"); + decodeSecret(sec_str, &kp.secret_key) catch return out.print("invalid secret key\n", .{}); + try nostr.crypto.getPublicKey(&kp.secret_key, &kp.public_key); + } + + var relay = try nostr.relay.Relay.init(arena, relay_url, .{ .read_timeout_ms = read_timeout_ms }); + defer relay.deinit(); + try relay.connect(); + defer relay.disconnect(); + + try relay.client.?.sendText(msg_json); + + var authed = false; + var resent = false; + var seen: usize = 0; + while (seen < max_req_events) : (seen += 1) { + var msg = (try relay.receive()) orelse break; + defer msg.deinit(); + switch (msg.msg_type) { + .auth => { + if (do_auth and !authed) { + if (msg.subscription_id) |challenge| { + try sendAuth(&relay, relay_url, &kp, challenge); + authed = true; + } + } + continue; + }, + .ok => if (authed and !resent) { + // The relay's ack of our auth event: now repeat the message. + try relay.client.?.sendText(msg_json); + resent = true; + continue; + }, + .closed => if (do_auth and !resent and authRequired(msg.message)) continue, + else => {}, + } + try printSanitized(io, out, msg.raw); + switch (msg.msg_type) { + .eose, .closed, .count, .ok, .notice => break, + else => {}, + } + } +} + fn cmdCount(arena: Allocator, out: *Io.Writer, args: []const [:0]const u8) !void { const q = (try parseQuery(arena, out, args)) orelse return; diff --git a/tests/integration_relay.sh b/tests/integration_relay.sh index 953cc79..90525f2 100755 --- a/tests/integration_relay.sh +++ b/tests/integration_relay.sh @@ -47,6 +47,12 @@ chk "REQ until in the past is empty" 0 "$(req -t t=$TAG -u 1)" # --- NIP-45 COUNT (noz prints the bare count) --- chk "COUNT returns the event count" 1 "$(timeout 10 $NOZ count -t t=$TAG "$R" 2>/dev/null)" +# --- send: a raw client message, replies printed until the terminating one --- +SENT=$(timeout 10 $NOZ send "$R" "[\"REQ\",\"raw\",{\"ids\":[\"$ID\"]},{\"ids\":[\"$ID\"]}]" 2>/dev/null) +chk "send prints the matching EVENT" 1 "$(echo "$SENT" | grep -c "^\[\"EVENT\",\"raw\"")" +chk "send stops at EOSE" '["EOSE","raw"]' "$(echo "$SENT" | tail -1)" +chk "send prints a COUNT reply" 1 "$(timeout 10 $NOZ send "$R" "[\"COUNT\",\"c\",{\"ids\":[\"$ID\"]}]" 2>/dev/null | grep -c '"count":1')" + # --- A fetched event survives verify end to end --- EV=$(timeout 10 $NOZ req -i "$ID" "$R" 2>/dev/null | head -1) chk "fetched event passes verify" valid "$(echo "$EV" | $NOZ verify)" From 801e49d17e45621eb421ebf16b1c4ed89cb09242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Mon, 5 Oct 2026 23:42:01 -0400 Subject: [PATCH 2/2] Match the auth ack by id in send and test --auth --- .github/workflows/ci.yml | 16 ++++++++---- README.md | 2 +- src/main.zig | 51 +++++++++++++++++++++++++++------------ tests/integration_auth.sh | 46 +++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 21 deletions(-) create mode 100755 tests/integration_auth.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1540096..4018769 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,17 +73,23 @@ jobs: - name: Start relays run: | - start() { # port datadir - nohup env WISP_HOST=127.0.0.1 WISP_PORT="$1" WISP_STORAGE_PATH="$2" \ - "$WISP" relay > "relay-$1.log" 2>&1 & - for i in $(seq 1 40); do nc -z 127.0.0.1 "$1" && return 0; sleep 0.5; done - echo "relay $1 did not start listening"; cat "relay-$1.log"; return 1 + start() { # port datadir [env..] + local port=$1 dir=$2; shift 2 + nohup env WISP_HOST=127.0.0.1 WISP_PORT="$port" WISP_STORAGE_PATH="$dir" "$@" \ + "$WISP" relay > "relay-$port.log" 2>&1 & + for i in $(seq 1 40); do nc -z 127.0.0.1 "$port" && return 0; sleep 0.5; done + echo "relay $port did not start listening"; cat "relay-$port.log"; return 1 } start 7777 "$RUNNER_TEMP/w1" start 7778 "$RUNNER_TEMP/w2" + # AUTH available (relay URL set) for the NIP-42 send tests + start 7779 "$RUNNER_TEMP/w3" WISP_RELAY_URL=ws://127.0.0.1:7779 - name: Relay round-trip tests run: NOZ=./zig-out/bin/noz bash tests/integration_relay.sh ws://127.0.0.1:7777 + - name: NIP-42 send --auth tests + run: NOZ=./zig-out/bin/noz bash tests/integration_auth.sh ws://127.0.0.1:7779 + - name: NIP-77 sync tests run: NOZ=./zig-out/bin/noz bash tests/integration_sync.sh ws://127.0.0.1:7777 ws://127.0.0.1:7778 diff --git a/README.md b/README.md index 48e3c00..11a3f3b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ noz decode Decode a NIP-19 entity (npub/nsec/note/nevent/naddr noz verify [event-json] Verify an event's id and signature (reads stdin if no arg) ``` -The secret for `key`/`event`/`send` can also come from `NOSTR_SECRET_KEY` instead of +The secret for `key`/`event` (and `send --auth`) can also come from `NOSTR_SECRET_KEY` instead of `--sec`/``, so it never lands in your shell history. ## Examples diff --git a/src/main.zig b/src/main.zig index 5211211..935f8f9 100644 --- a/src/main.zig +++ b/src/main.zig @@ -344,7 +344,7 @@ fn publishWithAuth(relay: *nostr.relay.Relay, event: *const nostr.Event, url: [] if (msg.subscription_id) |challenge| { // 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; + _ = 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. @@ -390,7 +390,7 @@ fn authRequired(message: ?[]const u8) bool { } // 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 { +fn sendAuth(relay: *nostr.relay.Relay, url: []const u8, kp: *const nostr.Keypair, challenge: []const u8) ![64]u8 { var b = nostr.EventBuilder{}; _ = b.setKind(22242); _ = b.setContent(""); @@ -407,6 +407,9 @@ fn sendAuth(relay: *nostr.relay.Relay, url: []const u8, kp: *const nostr.Keypair var ev = try nostr.Event.parse(ev_json); defer ev.deinit(); try relay.authenticate(&ev); + var id_hex: [65]u8 = undefined; + ev.idHex(&id_hex); + return id_hex[0..64].*; } const Query = struct { url: []const u8, filter: nostr.Filter }; @@ -576,34 +579,52 @@ fn cmdSend(env_sec: ?[]const u8, io: Io, arena: Allocator, out: *Io.Writer, args try relay.client.?.sendText(msg_json); - var authed = false; + // Once the auth event is out, replies to the first, unauthenticated send are + // dropped until the relay acks the auth event; the message is then re-sent + // and only replies to that copy are printed. + var auth_id: ?[64]u8 = null; var resent = false; var seen: usize = 0; while (seen < max_req_events) : (seen += 1) { var msg = (try relay.receive()) orelse break; defer msg.deinit(); + const waiting = do_auth and !resent; switch (msg.msg_type) { .auth => { - if (do_auth and !authed) { - if (msg.subscription_id) |challenge| { - try sendAuth(&relay, relay_url, &kp, challenge); - authed = true; - } + if (do_auth and auth_id == null) { + if (msg.subscription_id) |challenge| auth_id = try sendAuth(&relay, relay_url, &kp, challenge); } continue; }, - .ok => if (authed and !resent) { - // The relay's ack of our auth event: now repeat the message. - try relay.client.?.sendText(msg_json); - resent = true; - continue; + .ok => if (auth_id) |aid| { + if (msg.subscription_id) |ok_id| { + if (std.mem.eql(u8, ok_id, &aid)) { + if (!msg.success) { + try printSanitized(io, out, msg.raw); + break; + } + if (!resent) { + try relay.client.?.sendText(msg_json); + resent = true; + } + continue; + } + } }, - .closed => if (do_auth and !resent and authRequired(msg.message)) continue, else => {}, } + if (waiting) { + if (auth_id != null) continue; + const auth_rejection = switch (msg.msg_type) { + .closed, .ok => authRequired(msg.message), + else => false, + }; + if (auth_rejection) continue; + } try printSanitized(io, out, msg.raw); switch (msg.msg_type) { - .eose, .closed, .count, .ok, .notice => break, + .eose, .closed, .count, .ok => break, + .notice => if (!waiting) break, else => {}, } } diff --git a/tests/integration_auth.sh b/tests/integration_auth.sh new file mode 100755 index 0000000..a33ccef --- /dev/null +++ b/tests/integration_auth.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# NIP-42 tests for `noz send --auth`. The relay must have AUTH available +# (for wisp: WISP_RELAY_URL set to the URL passed here) and reject NIP-70 +# protected events from unauthenticated connections. +# +# Usage: tests/integration_auth.sh +# NOZ=path overrides the binary (default ./zig-out/bin/noz) +# +# Exits non-zero if any assertion fails. +set -u +R="${1:?relay url required}" +NOZ="${NOZ:-./zig-out/bin/noz}" + +SEC1=0000000000000000000000000000000000000000000000000000000000000001 + +pass=0 +fail=0 + +chk() { # desc expected actual + if [ "$2" = "$3" ]; then + echo "ok - $1" + pass=$((pass + 1)) + else + echo "FAIL - $1 (expected '$2', got '$3')" + fail=$((fail + 1)) + fi +} + +# A signed-but-unpublished protected event, sent raw. +EV=$($NOZ event --sec $SEC1 -t - -c "noz auth $RANDOM$RANDOM" 2>/dev/null) +ID=$(echo "$EV" | grep -oE '"id":"[a-f0-9]{64}"' | head -1 | cut -d'"' -f4) + +OUT=$(timeout 15 $NOZ send "$R" "[\"EVENT\",$EV]" 2>/dev/null) +chk "protected EVENT without --auth is rejected" 1 "$(echo "$OUT" | tail -1 | grep -c "^\[\"OK\",\"$ID\",false")" + +OUT=$(timeout 15 $NOZ send "$R" "[\"EVENT\",$EV]" --sec $SEC1 --auth 2>/dev/null) +chk "protected EVENT with --auth is accepted" 1 "$(echo "$OUT" | grep -c "^\[\"OK\",\"$ID\",true")" +chk "only the reply to the user's message is printed" 1 "$(echo "$OUT" | wc -l | tr -d ' ')" + +OUT=$(timeout 15 $NOZ send "$R" "[\"REQ\",\"a\",{\"ids\":[\"$ID\"]}]" --sec $SEC1 --auth 2>/dev/null) +chk "authenticated REQ ends at EOSE" '["EOSE","a"]' "$(echo "$OUT" | tail -1)" +chk "authenticated REQ returns the event once" 1 "$(echo "$OUT" | grep -c '^\["EVENT"')" + +echo +echo "passed $pass, failed $fail" +[ "$fail" -eq 0 ]