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 2d777cd..11a3f3b 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` (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 70eedaa..935f8f9 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")) { @@ -340,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. @@ -386,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(""); @@ -403,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 }; @@ -528,6 +535,101 @@ 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); + + // 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 auth_id == null) { + if (msg.subscription_id) |challenge| auth_id = try sendAuth(&relay, relay_url, &kp, challenge); + } + 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; + } + } + }, + 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 => break, + .notice => if (!waiting) 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_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 ] 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)"