Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ noz req <url> [filters..] Subscribe and print matching events (to EOSE)
noz count <url> [filters..] Count matching events (NIP-45)
noz relay <url> Print the NIP-11 relay info doc
noz sync <src> <dst> [..] NIP-77 reconcile src's events into dst
noz send <url> <json> Send one raw client message, print replies (--sec <k> --auth for NIP-42)
noz decode <bech32|hex> 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`/`<seckey>`, so it never lands in your shell history.

## Examples
Expand Down
106 changes: 104 additions & 2 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const usage =
\\ count <url> [filters..] Count matching events (NIP-45)
\\ relay <url> Print the NIP-11 relay information document
\\ sync <src> <dst> [filters..] NIP-77 reconcile src's events into dst
\\ send <url> <json> [--sec <k> --auth] Send one raw client message, print replies
\\ until EOSE/CLOSED/COUNT/OK/NOTICE (--auth answers NIP-42)
\\ decode <bech32|hex> 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)
\\
Expand Down Expand Up @@ -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")) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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("");
Expand All @@ -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 };
Expand Down Expand Up @@ -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;

Expand Down
46 changes: 46 additions & 0 deletions tests/integration_auth.sh
Original file line number Diff line number Diff line change
@@ -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 <relay-url>
# 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 ]
6 changes: 6 additions & 0 deletions tests/integration_relay.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
Loading