From 6b32ebc36cf59438737d808e6085ea15cc04ee12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Tue, 6 Oct 2026 09:53:11 -0400 Subject: [PATCH] Authorize NWC clients, verify requests, and move to libnostr-z 0.5.0 --- .github/workflows/ci.yml | 44 +++ .gitignore | 1 + README.md | 13 +- build.zig | 8 +- build.zig.zon | 4 +- puck.toml.example | 1 + src/config.zig | 151 ++++++++- src/lnbits.zig | 42 ++- src/main.zig | 700 ++++++++++++++++++++++++++++++++++----- src/relay.zig | 220 ++++++------ 10 files changed, 949 insertions(+), 235 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..82121c7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + build: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + zig: [0.16.0, 0.17.0] + + runs-on: ${{ matrix.os }} + if: github.event.pull_request.draft != true + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Install dependencies (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libsecp256k1-dev libssl-dev + + - name: Install dependencies (macOS) + if: runner.os == 'macOS' + run: brew install secp256k1 openssl@3 + + - name: Setup Zig + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + with: + version: ${{ matrix.zig }} + + - name: Build + run: zig build + + - name: Test + run: zig build test diff --git a/.gitignore b/.gitignore index 9a3e480..a85759e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ zig-out/ .zig-cache/ +zig-pkg/ puck.toml diff --git a/README.md b/README.md index 19392b6..cee49da 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,10 @@ Nostr Wallet Connect (NIP-47) server in Zig with LNbits backend. ```toml [nostr] -privkey = "nsec1..." # or hex +# nsec or hex; the config parser does not accept trailing comments +privkey = "nsec1..." relay = "wss://relay.example.com" +client_pubkeys = [""] [lnbits] host = "http://127.0.0.1:5000" @@ -41,11 +43,16 @@ nostr+walletconnect://?relay=&secret= - `PUBKEY` - Puck's pubkey (shown on startup) - `RELAY` - Your relay URL -- `CLIENT_SECRET` - Random 32-byte hex (client generates this) +- `CLIENT_SECRET` - Random 32-byte hex secret for this connection + +Add the public key of `CLIENT_SECRET` (hex or npub, for example from `nak key public `) to `client_pubkeys`, one entry per connection, and restart Puck. Requests from any other key are ignored. Puck does not store the secret. + +Requests must use NIP-44 (`["encryption", "nip44_v2"]`); NIP-04 requests get an `UNSUPPORTED_ENCRYPTION` error. Requests older than 10 minutes, past their `expiration` tag, or already seen are ignored. Use with Alby, Amethyst, Damus, or any NWC-compatible app. ## Requirements -- Zig 0.15+ +- Zig 0.16.0 or 0.17.0 +- OpenSSL 3 development libraries (`libssl-dev` on Debian/Ubuntu, `openssl@3` on macOS) - LNbits instance with funded wallet diff --git a/build.zig b/build.zig index 74fe135..afd0823 100644 --- a/build.zig +++ b/build.zig @@ -21,14 +21,14 @@ pub fn build(b: *std.Build) void { }), }); - exe.root_module.strip = optimize == .ReleaseSmall or optimize == .ReleaseFast; - exe.linkLibC(); + exe.root_module.strip = optimize == std.builtin.OptimizeMode.ReleaseSmall or optimize == std.builtin.OptimizeMode.ReleaseFast; + exe.root_module.link_libc = true; b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); run_cmd.step.dependOn(b.getInstallStep()); - if (b.args) |args| run_cmd.addArgs(args); + if (comptime @hasDecl(std.Build.Step.Run, "addPassthruArgs")) run_cmd.addPassthruArgs() else if (b.args) |args| run_cmd.addArgs(args); b.step("run", "Run the NWC server").dependOn(&run_cmd.step); const unit_tests = b.addTest(.{ @@ -41,7 +41,7 @@ pub fn build(b: *std.Build) void { }, }), }); - unit_tests.linkLibC(); + unit_tests.root_module.link_libc = true; const run_unit_tests = b.addRunArtifact(unit_tests); b.step("test", "Run unit tests").dependOn(&run_unit_tests.step); diff --git a/build.zig.zon b/build.zig.zon index 0fa7082..ba1746f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -4,8 +4,8 @@ .fingerprint = 0x8cf1558f8ea40aa6, .dependencies = .{ .nostr = .{ - .url = "https://github.com/privkeyio/libnostr-z/archive/refs/tags/v0.1.7.tar.gz", - .hash = "nostr-0.1.7-JY6OcFu-BQCqNx3PvtNKi6Af_rbeXCh_4CkIth3srMdP", + .url = "https://github.com/privkeyio/libnostr-z/archive/5accad27a7ad71d503cd3c8152ea9540d2c23cda.tar.gz", + .hash = "nostr-0.5.0-JY6OcKMgEABIisCx3kmE4ybZbmnO5PyWyodAGjukxEJs", }, }, .paths = .{ diff --git a/puck.toml.example b/puck.toml.example index f501cea..373d990 100644 --- a/puck.toml.example +++ b/puck.toml.example @@ -1,6 +1,7 @@ [nostr] privkey = "nsec1..." relay = "wss://relay.getalby.com/v1" +client_pubkeys = [""] [lnbits] host = "https://legend.lnbits.com" diff --git a/src/config.zig b/src/config.zig index fee4f1c..d286c82 100644 --- a/src/config.zig +++ b/src/config.zig @@ -5,28 +5,31 @@ pub const Config = struct { privkey: [32]u8, pubkey: [32]u8, relay: []const u8, + client_pubkeys: std.ArrayListUnmanaged([32]u8), lnbits_host: []const u8, lnbits_admin_key: []const u8, _allocated: std.ArrayListUnmanaged([]const u8), _allocator: std.mem.Allocator, - pub fn load(allocator: std.mem.Allocator, path: []const u8) !Config { + pub fn load(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !Config { + const content = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)); + defer allocator.free(content); + return parse(allocator, content); + } + + pub fn parse(allocator: std.mem.Allocator, content: []const u8) !Config { var config = Config{ .privkey = undefined, .pubkey = undefined, .relay = "", + .client_pubkeys = .empty, .lnbits_host = "", .lnbits_admin_key = "", - ._allocated = .{}, + ._allocated = .empty, ._allocator = allocator, }; - - const file = try std.fs.cwd().openFile(path, .{}); - defer file.close(); - - const content = try file.readToEndAlloc(allocator, 1024 * 1024); - defer allocator.free(content); + errdefer config.deinit(); var section: []const u8 = ""; var lines = std.mem.splitScalar(u8, content, '\n'); @@ -43,11 +46,7 @@ pub const Config = struct { const eq_pos = std.mem.indexOf(u8, trimmed, "=") orelse continue; const key = std.mem.trim(u8, trimmed[0..eq_pos], " \t"); - var value = std.mem.trim(u8, trimmed[eq_pos + 1 ..], " \t"); - - if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') { - value = value[1 .. value.len - 1]; - } + const value = unquote(std.mem.trim(u8, trimmed[eq_pos + 1 ..], " \t")); if (std.mem.eql(u8, section, "nostr")) { if (std.mem.eql(u8, key, "privkey")) { @@ -56,6 +55,8 @@ pub const Config = struct { has_privkey = true; } else if (std.mem.eql(u8, key, "relay")) { config.relay = try config.allocString(value); + } else if (std.mem.eql(u8, key, "client_pubkeys")) { + try config.addClientPubkeys(allocator, value); } } else if (std.mem.eql(u8, section, "lnbits")) { if (std.mem.eql(u8, key, "host")) { @@ -68,18 +69,50 @@ pub const Config = struct { if (!has_privkey) return error.MissingPrivkey; if (config.relay.len == 0) return error.MissingRelay; + if (config.client_pubkeys.items.len == 0) return error.MissingClientPubkeys; if (config.lnbits_host.len == 0) return error.MissingLnbitsHost; if (config.lnbits_admin_key.len == 0) return error.MissingLnbitsKey; return config; } + pub fn isAuthorized(self: *const Config, pubkey: *const [32]u8) bool { + for (self.client_pubkeys.items) |*pk| { + if (std.mem.eql(u8, pk, pubkey)) return true; + } + return false; + } + + fn unquote(value: []const u8) []const u8 { + if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') { + return value[1 .. value.len - 1]; + } + return value; + } + + fn addClientPubkeys(self: *Config, allocator: std.mem.Allocator, value: []const u8) !void { + var list = value; + if (list.len >= 2 and list[0] == '[' and list[list.len - 1] == ']') { + list = list[1 .. list.len - 1]; + } + var items = std.mem.splitScalar(u8, list, ','); + while (items.next()) |item| { + const entry = unquote(std.mem.trim(u8, item, " \t")); + if (entry.len == 0) continue; + const pk = try parsePubkey(allocator, entry); + if (!self.isAuthorized(&pk)) try self.client_pubkeys.append(self._allocator, pk); + } + } + fn parsePrivkey(allocator: std.mem.Allocator, value: []const u8) ![32]u8 { if (std.mem.startsWith(u8, value, "nsec1")) { const decoded = nostr.bech32.decodeNostr(allocator, value) catch return error.InvalidPrivkey; switch (decoded) { .seckey => |sk| return sk, - else => return error.InvalidPrivkey, + else => { + decoded.deinit(allocator); + return error.InvalidPrivkey; + }, } } else if (value.len == 64) { var key: [32]u8 = undefined; @@ -89,8 +122,27 @@ pub const Config = struct { return error.InvalidPrivkey; } + fn parsePubkey(allocator: std.mem.Allocator, value: []const u8) ![32]u8 { + if (std.mem.startsWith(u8, value, "npub1")) { + const decoded = nostr.bech32.decodeNostr(allocator, value) catch return error.InvalidClientPubkey; + switch (decoded) { + .pubkey => |pk| return pk, + else => { + decoded.deinit(allocator); + return error.InvalidClientPubkey; + }, + } + } else if (value.len == 64) { + var key: [32]u8 = undefined; + _ = std.fmt.hexToBytes(&key, value) catch return error.InvalidClientPubkey; + return key; + } + return error.InvalidClientPubkey; + } + fn allocString(self: *Config, value: []const u8) ![]const u8 { const copy = try self._allocator.dupe(u8, value); + errdefer self._allocator.free(copy); try self._allocated.append(self._allocator, copy); return copy; } @@ -100,5 +152,76 @@ pub const Config = struct { self._allocator.free(s); } self._allocated.deinit(self._allocator); + self.client_pubkeys.deinit(self._allocator); + std.crypto.secureZero(u8, &self.privkey); } }; + +const test_privkey = "0000000000000000000000000000000000000000000000000000000000000001"; +const test_client = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + +test "parse full config with client pubkey list" { + try nostr.init(); + defer nostr.cleanup(); + + const content = + \\[nostr] + \\privkey = " + ++ test_privkey ++ + \\" + \\relay = "ws://127.0.0.1:7777" + \\client_pubkeys = [" + ++ test_client ++ + \\", "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"] + \\ + \\[lnbits] + \\host = "http://127.0.0.1:5000" + \\admin_key = "abc" + ; + var config = try Config.parse(std.testing.allocator, content); + defer config.deinit(); + + try std.testing.expectEqualStrings("ws://127.0.0.1:7777", config.relay); + try std.testing.expectEqual(@as(usize, 2), config.client_pubkeys.items.len); + + var client: [32]u8 = undefined; + _ = try std.fmt.hexToBytes(&client, test_client); + try std.testing.expect(config.isAuthorized(&client)); + const stranger: [32]u8 = @splat(1); + try std.testing.expect(!config.isAuthorized(&stranger)); +} + +test "config without client pubkeys is rejected" { + try nostr.init(); + defer nostr.cleanup(); + + const content = + \\[nostr] + \\privkey = " + ++ test_privkey ++ + \\" + \\relay = "ws://127.0.0.1:7777" + \\[lnbits] + \\host = "http://127.0.0.1:5000" + \\admin_key = "abc" + ; + try std.testing.expectError(error.MissingClientPubkeys, Config.parse(std.testing.allocator, content)); +} + +test "invalid client pubkey is rejected" { + try nostr.init(); + defer nostr.cleanup(); + + const content = + \\[nostr] + \\privkey = " + ++ test_privkey ++ + \\" + \\relay = "ws://127.0.0.1:7777" + \\client_pubkeys = "not-a-key" + \\[lnbits] + \\host = "http://127.0.0.1:5000" + \\admin_key = "abc" + ; + try std.testing.expectError(error.InvalidClientPubkey, Config.parse(std.testing.allocator, content)); +} diff --git a/src/lnbits.zig b/src/lnbits.zig index 213f135..aa221e6 100644 --- a/src/lnbits.zig +++ b/src/lnbits.zig @@ -6,6 +6,7 @@ pub const LnbitsError = error{ PaymentFailed, InvoiceNotFound, InsufficientBalance, + InvalidAmount, }; pub const WalletInfo = struct { @@ -40,17 +41,17 @@ pub const Client = struct { admin_key: []const u8, http_client: std.http.Client, - pub fn init(allocator: std.mem.Allocator, host: []const u8, admin_key: []const u8) Client { + pub fn init(allocator: std.mem.Allocator, io: std.Io, host: []const u8, admin_key: []const u8) Client { return .{ .allocator = allocator, .host = host, .admin_key = admin_key, - .http_client = .{ .allocator = allocator }, + .http_client = .{ .allocator = allocator, .io = io }, }; } pub fn deinit(self: *Client) void { - self.http_client.connection_pool.deinit(); + self.http_client.deinit(); } pub fn getWallet(self: *Client) !WalletInfo { @@ -62,12 +63,13 @@ pub const Client = struct { return .{ .name = name, - .balance = @intCast(balance_msat), + .balance = std.math.cast(u64, balance_msat) orelse return LnbitsError.InvalidResponse, }; } pub fn createInvoice(self: *Client, amount_msat: u64, memo: ?[]const u8) !Invoice { - var body_buf: [512]u8 = undefined; + if (amount_msat == 0 or amount_msat % 1000 != 0) return LnbitsError.InvalidAmount; + var body_buf: [2048]u8 = undefined; const amount_sats = amount_msat / 1000; const body = if (memo) |m| std.fmt.bufPrint(&body_buf, "{{\"out\":false,\"amount\":{d},\"memo\":\"{s}\"}}", .{ amount_sats, m }) catch return LnbitsError.RequestFailed @@ -102,6 +104,7 @@ pub const Client = struct { } pub fn lookupPayment(self: *Client, payment_hash: []const u8) !PaymentDetails { + if (!isPaymentHash(payment_hash)) return LnbitsError.InvoiceNotFound; var path_buf: [256]u8 = undefined; const path = std.fmt.bufPrint(&path_buf, "/api/v1/payments/{s}", .{payment_hash}) catch return LnbitsError.RequestFailed; @@ -133,10 +136,11 @@ pub const Client = struct { const uri = std.Uri.parse(uri_str) catch return LnbitsError.RequestFailed; var req = self.http_client.request(method, uri, .{ + .headers = .{ .accept_encoding = .omit, .content_type = .{ .override = "application/json" } }, .extra_headers = &.{ .{ .name = "X-Api-Key", .value = self.admin_key }, - .{ .name = "Content-Type", .value = "application/json" }, }, + .redirect_behavior = .unhandled, }) catch return LnbitsError.RequestFailed; defer req.deinit(); @@ -154,12 +158,20 @@ pub const Client = struct { return LnbitsError.RequestFailed; } - var body_reader = response.reader(response_buf); + var transfer_buf: [4096]u8 = undefined; + const body_reader = response.reader(&transfer_buf); const len = body_reader.readSliceShort(response_buf) catch return LnbitsError.RequestFailed; + if (len == response_buf.len) return LnbitsError.InvalidResponse; return response_buf[0..len]; } }; +pub fn isPaymentHash(value: []const u8) bool { + if (value.len != 64) return false; + for (value) |c| if (!std.ascii.isHex(c)) return false; + return true; +} + fn extractJsonString(json: []const u8, key: []const u8) ?[]const u8 { var search_buf: [68]u8 = undefined; const search = std.fmt.bufPrint(&search_buf, "\"{s}\":", .{key}) catch return null; @@ -212,3 +224,19 @@ fn extractJsonBool(json: []const u8, key: []const u8) ?bool { if (pos + 5 <= json.len and std.mem.eql(u8, json[pos..][0..5], "false")) return false; return null; } + +test "payment hash validation" { + var hash: [64]u8 = @splat('a'); + try std.testing.expect(isPaymentHash(&hash)); + try std.testing.expect(!isPaymentHash(hash[0..63])); + try std.testing.expect(!isPaymentHash("../wallet")); + hash[62] = '/'; + try std.testing.expect(!isPaymentHash(&hash)); +} + +test "createInvoice rejects amounts that are not whole sats" { + var client = Client.init(std.testing.allocator, std.testing.io, "http://127.0.0.1:1", "key"); + defer client.deinit(); + try std.testing.expectError(LnbitsError.InvalidAmount, client.createInvoice(0, null)); + try std.testing.expectError(LnbitsError.InvalidAmount, client.createInvoice(1500, null)); +} diff --git a/src/main.zig b/src/main.zig index 4890f9e..de81eea 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,9 +3,11 @@ const nostr = @import("nostr"); const Config = @import("config.zig").Config; const LnbitsClient = @import("lnbits.zig").Client; const Relay = @import("relay.zig").Relay; -const RelayMessage = @import("relay.zig").Message; -const supported_methods = [_]nostr.nwc.Method{ +const nwc = nostr.nwc; +const utils = nostr.utils; + +const supported_methods = [_]nwc.Method{ .get_balance, .get_info, .make_invoice, @@ -13,37 +15,42 @@ const supported_methods = [_]nostr.nwc.Method{ .lookup_invoice, }; +/// Requests older than this are ignored, and request ids are remembered for at +/// least this long, so a captured request cannot be replayed to run twice. +const max_request_age_s: i64 = 600; + var g_shutdown: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); +var g_relay_fd: std.atomic.Value(std.posix.fd_t) = std.atomic.Value(std.posix.fd_t).init(-1); -fn signalHandler(_: c_int) callconv(std.builtin.CallingConvention.c) void { +fn signalHandler(_: std.posix.SIG) callconv(std.builtin.CallingConvention.c) void { g_shutdown.store(true, .release); + const fd = g_relay_fd.load(.acquire); + if (fd >= 0) _ = std.c.shutdown(fd, std.posix.SHUT.RD); } -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); - - const args = try std.process.argsAlloc(allocator); - defer std.process.argsFree(allocator, args); +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + const args = try init.minimal.args.toSlice(init.arena.allocator()); const config_path = if (args.len > 1) args[1] else "puck.toml"; - var config = Config.load(allocator, config_path) catch |err| { + try nostr.init(); + defer nostr.cleanup(); + + var config = Config.load(allocator, io, config_path) catch |err| { std.log.err("Failed to load config from {s}: {}", .{ config_path, err }); return err; }; defer config.deinit(); - try nostr.init(); - defer nostr.cleanup(); - std.log.info("Puck NWC Server starting", .{}); var pubkey_hex: [64]u8 = undefined; nostr.hex.encode(&config.pubkey, &pubkey_hex); std.log.info("Pubkey: {s}", .{&pubkey_hex}); std.log.info("Relay: {s}", .{config.relay}); std.log.info("LNbits: {s}", .{config.lnbits_host}); + std.log.info("Authorized client pubkeys: {d}", .{config.client_pubkeys.items.len}); const sa = std.posix.Sigaction{ .handler = .{ .handler = signalHandler }, @@ -53,69 +60,91 @@ pub fn main() !void { std.posix.sigaction(std.posix.SIG.INT, &sa, null); std.posix.sigaction(std.posix.SIG.TERM, &sa, null); - var lnbits = LnbitsClient.init(allocator, config.lnbits_host, config.lnbits_admin_key); + const ignore_sa = std.posix.Sigaction{ + .handler = .{ .handler = std.posix.SIG.IGN }, + .mask = std.posix.sigemptyset(), + .flags = 0, + }; + std.posix.sigaction(std.posix.SIG.PIPE, &ignore_sa, null); + + var lnbits = LnbitsClient.init(allocator, io, config.lnbits_host, config.lnbits_admin_key); defer lnbits.deinit(); + var guard: ReplayGuard = .{}; + defer guard.deinit(allocator); + while (!g_shutdown.load(.acquire)) { - runEventLoop(allocator, &config, &lnbits) catch |err| { + runEventLoop(allocator, &config, &lnbits, &guard) catch |err| { std.log.err("Event loop error: {}", .{err}); }; if (!g_shutdown.load(.acquire)) { std.log.info("Reconnecting in 5 seconds...", .{}); - std.Thread.sleep(5 * std.time.ns_per_s); + std.Io.sleep(io, .{ .nanoseconds = 5 * std.time.ns_per_s }, .awake) catch {}; } } std.log.info("Shutdown complete", .{}); } -fn runEventLoop(allocator: std.mem.Allocator, config: *Config, lnbits: *LnbitsClient) !void { +fn runEventLoop(allocator: std.mem.Allocator, config: *Config, lnbits: *LnbitsClient, guard: *ReplayGuard) !void { std.log.info("Connecting to relay...", .{}); var relay = try Relay.connect(allocator, config.relay); - defer relay.deinit(); + g_relay_fd.store(relay.socket(), .release); + defer { + g_relay_fd.store(-1, .release); + relay.deinit(); + } std.log.info("Connected to relay", .{}); - try publishInfoEvent(allocator, config, &relay); + try publishInfoEvent(config, &relay); - var filter_buf: [256]u8 = undefined; - var pubkey_hex: [64]u8 = undefined; - nostr.hex.encode(&config.pubkey, &pubkey_hex); - const filter = std.fmt.bufPrint(&filter_buf, "{{\"kinds\":[{d}],\"#p\":[\"{s}\"]}}", .{ nostr.nwc.Kind.request, &pubkey_hex }) catch return error.BufferTooSmall; + var filter_buf: [4096]u8 = undefined; + const filter = try buildRequestFilter(config, nostr.io.timestamp(), &filter_buf); try relay.subscribe("nwc", filter); std.log.info("Subscribed to NWC requests", .{}); while (!g_shutdown.load(.acquire)) { - const msg = relay.receive() catch |err| { - if (err == error.Closed) return err; - continue; - }; - - if (msg) |m| { - var message = m; - defer relay.freeMessage(&message); - switch (message) { - .event => |e| { - handleEvent(allocator, config, lnbits, &relay, e.event_json) catch |err| { - std.log.err("Failed to handle event: {}", .{err}); - }; - }, - .eose => std.log.debug("EOSE received", .{}), - .notice => |n| std.log.warn("Notice: {s}", .{n}), - .ok => |o| { - if (!o.success) { - std.log.warn("Event rejected: {s}", .{o.message}); - } - }, - .closed => |c| std.log.warn("Subscription closed: {s}", .{c.message}), - .unknown => {}, - } + var message = try relay.receive(); + defer relay.freeMessage(&message); + switch (message) { + .event => |e| { + handleEvent(allocator, config, lnbits, &relay, guard, e.event_json) catch |err| { + std.log.err("Failed to handle event: {}", .{err}); + }; + }, + .eose => std.log.debug("EOSE received", .{}), + .notice => |n| std.log.warn("Notice: {s}", .{n}), + .ok => |o| { + if (!o.success) { + std.log.warn("Event rejected: {s}", .{o.message}); + } + }, + .closed => |c| { + std.log.warn("Subscription closed: {s}", .{c.message}); + return error.SubscriptionClosed; + }, + .unknown => {}, } } } -fn publishInfoEvent(allocator: std.mem.Allocator, config: *Config, relay: *Relay) !void { +fn buildRequestFilter(config: *const Config, now: i64, buf: []u8) ![]const u8 { + var w = std.Io.Writer.fixed(buf); + var hex_buf: [64]u8 = undefined; + nostr.hex.encode(&config.pubkey, &hex_buf); + try w.print("{{\"kinds\":[{d}],\"#p\":[\"{s}\"],\"authors\":[", .{ nwc.Kind.request, &hex_buf }); + for (config.client_pubkeys.items, 0..) |*pk, i| { + if (i > 0) try w.writeByte(','); + nostr.hex.encode(pk, &hex_buf); + try w.print("\"{s}\"", .{&hex_buf}); + } + try w.print("],\"since\":{d}}}", .{now - max_request_age_s}); + return w.buffered(); +} + +fn publishInfoEvent(config: *Config, relay: *Relay) !void { var content_buf: [256]u8 = undefined; var content_pos: usize = 0; for (supported_methods, 0..) |method, i| { @@ -129,60 +158,244 @@ fn publishInfoEvent(allocator: std.mem.Allocator, config: *Config, relay: *Relay } const content = content_buf[0..content_pos]; - var keypair = nostr.Keypair{ + const keypair = nostr.Keypair{ .secret_key = config.privkey, .public_key = config.pubkey, }; const tags = [_][]const []const u8{ - &[_][]const u8{ "encryption", "nip44_v2" }, + &[_][]const u8{ "encryption", nwc.Encryption.nip44_v2.toString() }, }; var builder = nostr.EventBuilder{}; - _ = builder.setKind(nostr.nwc.Kind.info).setContent(content).setTags(&tags); + _ = builder.setKind(nwc.Kind.info).setContent(content).setTags(&tags); try builder.sign(&keypair); var event_buf: [4096]u8 = undefined; const event_json = try builder.serialize(&event_buf); try relay.publish(event_json); - std.log.info("Published info event (kind {d})", .{nostr.nwc.Kind.info}); + std.log.info("Published info event (kind {d})", .{nwc.Kind.info}); +} - _ = allocator; +const RequestError = error{ + WrongKind, + NotAddressedToUs, + Unauthorized, + InvalidSignature, + Expired, + Stale, +}; + +/// Checks run before a request is decrypted: it must be a kind 23194 event +/// addressed to this wallet service, authored by a configured client key, +/// correctly signed, unexpired (NIP-47 `expiration` tag) and recent. +fn checkRequest(config: *const Config, event: *const nostr.Event, now: i64) RequestError!void { + if (event.kind() != nwc.Kind.request) return error.WrongKind; + if (!config.isAuthorized(event.pubkey())) return error.Unauthorized; + + var tags = utils.TagIterator.init(event.raw_json, "tags") orelse return error.NotAddressedToUs; + var addressed = false; + var expired = false; + while (tags.next()) |tag| { + if (std.mem.eql(u8, tag.name, "p")) { + var pk: [32]u8 = undefined; + if (tag.value.len == 64) { + if (std.fmt.hexToBytes(&pk, tag.value)) |_| { + if (std.mem.eql(u8, &pk, &config.pubkey)) addressed = true; + } else |_| {} + } + } else if (std.mem.eql(u8, tag.name, "expiration")) { + const at = std.fmt.parseInt(i64, tag.value, 10) catch { + expired = true; + continue; + }; + if (now > at) expired = true; + } + } + if (tags.malformed or !addressed) return error.NotAddressedToUs; + + event.validate() catch return error.InvalidSignature; + + if (expired) return error.Expired; + if (event.createdAt() < now - max_request_age_s) return error.Stale; } -fn handleEvent(allocator: std.mem.Allocator, config: *Config, lnbits: *LnbitsClient, relay: *Relay, event_json: []const u8) !void { +/// NIP-47: a request without an `encryption` tag uses NIP-04. +fn requestEncryption(event: *const nostr.Event) error{UnsupportedEncryption}!nwc.Encryption { + var tags = utils.TagIterator.init(event.raw_json, "tags") orelse return .nip04; + var found: ?nwc.Encryption = null; + while (tags.next()) |tag| { + if (!std.mem.eql(u8, tag.name, "encryption")) continue; + if (found != null) return error.UnsupportedEncryption; + found = nwc.Encryption.fromString(tag.value) orelse return error.UnsupportedEncryption; + } + return found orelse .nip04; +} + +/// Remembers the ids of accepted requests until they are older than +/// `max_request_age_s` and would be rejected as stale anyway. +// Linear scan; only authorized, signed requests are recorded, so it stays small. +const ReplayGuard = struct { + entries: std.ArrayListUnmanaged(Entry) = .empty, + + const Entry = struct { id: [32]u8, created_at: i64 }; + + fn firstSeen(self: *ReplayGuard, allocator: std.mem.Allocator, id: *const [32]u8, created_at: i64, now: i64) !bool { + var i: usize = 0; + while (i < self.entries.items.len) { + if (self.entries.items[i].created_at < now - max_request_age_s) { + _ = self.entries.swapRemove(i); + } else { + i += 1; + } + } + for (self.entries.items) |*e| { + if (std.mem.eql(u8, &e.id, id)) return false; + } + try self.entries.append(allocator, .{ .id = id.*, .created_at = created_at }); + return true; + } + + fn deinit(self: *ReplayGuard, allocator: std.mem.Allocator) void { + self.entries.deinit(allocator); + } +}; + +fn handleEvent(allocator: std.mem.Allocator, config: *Config, lnbits: *LnbitsClient, relay: *Relay, guard: *ReplayGuard, event_json: []const u8) !void { var event = try nostr.Event.parseWithAllocator(event_json, allocator); defer event.deinit(); - if (event.kind() != nostr.nwc.Kind.request) return; + var id_hex: [64]u8 = undefined; + nostr.hex.encode(&event.id_bytes, &id_hex); - const encrypted_content = event.content(); - if (encrypted_content.len == 0) return; + const now = nostr.io.timestamp(); + checkRequest(config, &event, now) catch |err| { + std.log.warn("Ignoring request {s}: {}", .{ &id_hex, err }); + return; + }; + if (!try guard.firstSeen(allocator, &event.id_bytes, event.createdAt(), now)) { + std.log.warn("Ignoring replayed request {s}", .{&id_hex}); + return; + } - const sender_pubkey = event.pubkey(); + const encryption = requestEncryption(&event) catch { + std.log.warn("Ignoring request {s}: unsupported encryption", .{&id_hex}); + return; + }; - const decrypted = nostr.crypto.nip44Decrypt(&config.privkey, sender_pubkey, encrypted_content, allocator) catch |err| { + const sender_pubkey = event.pubkey(); + const decrypted = decrypt(allocator, config, sender_pubkey, event.content(), encryption) catch |err| { std.log.err("Decryption failed: {}", .{err}); return; }; defer allocator.free(decrypted); - const request = nostr.nwc.Request.parseJson(decrypted) orelse { - std.log.err("Failed to parse NWC request", .{}); - return; + var response_buf: [4096]u8 = undefined; + const response_json = switch (encryption) { + .nip04 => blk: { + const method = nwc.Method.fromString(utils.extractJsonString(decrypted, "method") orelse return) orelse return; + const response = nwc.Response{ + .result_type = method, + .err = .{ .code = .unsupported_encryption, .message = "Use nip44_v2 encryption" }, + }; + break :blk try response.serialize(&response_buf); + }, + .nip44_v2 => if (nwc.Request.parseJson(decrypted)) |request| blk: { + std.log.info("Received {s} request", .{request.method.toString()}); + break :blk try handleRequest(request, utils.findJsonValue(decrypted, "params") orelse "{}", lnbits, &response_buf); + } else serializeInvalidRequest(decrypted, &response_buf) orelse { + std.log.err("Failed to parse NWC request", .{}); + return; + }, }; - std.log.info("Received {s} request", .{request.method.toString()}); + var event_buf: [16384]u8 = undefined; + const response_event = try buildResponseEvent(allocator, config, sender_pubkey, &event.id_bytes, response_json, encryption, &event_buf); + try relay.publish(response_event); + std.log.debug("Published response", .{}); +} - var response_buf: [4096]u8 = undefined; - const response_json = try handleRequest(request, lnbits, &response_buf); +fn decrypt(allocator: std.mem.Allocator, config: *const Config, sender: *const [32]u8, content: []const u8, encryption: nwc.Encryption) ![]u8 { + return switch (encryption) { + .nip44_v2 => nostr.crypto.nip44Decrypt(&config.privkey, sender, content, allocator), + .nip04 => nostr.nip04.decrypt(&config.privkey, sender, content, allocator), + }; +} + +/// Response for a request whose method is unknown or whose params are invalid. +fn serializeInvalidRequest(decrypted: []const u8, buf: []u8) ?[]const u8 { + const method_str = utils.extractJsonString(decrypted, "method") orelse return null; + if (nwc.Method.fromString(method_str)) |method| { + const response = nwc.Response{ + .result_type = method, + .err = .{ .code = .other, .message = "Invalid request parameters" }, + }; + return response.serialize(buf) catch null; + } + // method_str is raw JSON string content, so it is safe to place back between quotes. + return std.fmt.bufPrint(buf, "{{\"result_type\":\"{s}\",\"error\":{{\"code\":\"NOT_IMPLEMENTED\",\"message\":\"Method not supported\"}},\"result\":null}}", .{method_str}) catch null; +} + +/// Amount in msat encoded in a BOLT-11 invoice's human-readable part, or null +/// if the invoice has no amount. +fn bolt11AmountMsat(invoice: []const u8) error{InvalidInvoice}!?u64 { + const sep = std.mem.lastIndexOfScalar(u8, invoice, '1') orelse return error.InvalidInvoice; + const hrp = invoice[0..sep]; + if (hrp.len < 3 or !std.ascii.eqlIgnoreCase(hrp[0..2], "ln")) return error.InvalidInvoice; + + var pos: usize = 2; + while (pos < hrp.len and std.ascii.isAlphabetic(hrp[pos])) : (pos += 1) {} + if (pos == 2) return error.InvalidInvoice; + + const amount = hrp[pos..]; + if (amount.len == 0) return null; + + const last = std.ascii.toLower(amount[amount.len - 1]); + const digits = if (std.ascii.isDigit(last)) amount else amount[0 .. amount.len - 1]; + if (digits.len == 0 or digits[0] == '0') return error.InvalidInvoice; + for (digits) |c| if (!std.ascii.isDigit(c)) return error.InvalidInvoice; + const n = std.fmt.parseInt(u64, digits, 10) catch return error.InvalidInvoice; + + const msat_per_unit: u64 = switch (last) { + '0'...'9' => 100_000_000_000, + 'm' => 100_000_000, + 'u' => 100_000, + 'n' => 100, + 'p' => { + if (n % 10 != 0) return error.InvalidInvoice; + return n / 10; + }, + else => return error.InvalidInvoice, + }; + return std.math.mul(u64, n, msat_per_unit) catch error.InvalidInvoice; +} + +/// Rejects a pay_invoice request whose amount could be read differently by the +/// client and by the wallet: a malformed `amount`, an amount that differs from +/// the invoice's, or an amountless invoice (LNbits is never sent `amount`). +fn checkPayInvoice(params_json: []const u8, params: nwc.Request.PayInvoice) ?nwc.Response.Error { + for (params.invoice) |c| { + if (!std.ascii.isAlphanumeric(c)) return .{ .code = .other, .message = "Invalid invoice" }; + } + + if (utils.findJsonFieldStart(params_json, "amount")) |start| { + if (params.amount == null and !std.mem.startsWith(u8, params_json[start..], "null")) { + return .{ .code = .other, .message = "Invalid amount" }; + } + } + + const invoice_amount = (bolt11AmountMsat(params.invoice) catch return .{ .code = .other, .message = "Invalid invoice" }) orelse + return .{ .code = .not_implemented, .message = "Amountless invoices are not supported" }; - try publishResponse(allocator, config, relay, sender_pubkey, &event.id_bytes, response_json); + if (params.amount) |requested| { + if (requested != invoice_amount) return .{ .code = .other, .message = "Amount does not match invoice" }; + } + return null; } -fn handleRequest(request: nostr.nwc.Request, lnbits: *LnbitsClient, buf: []u8) ![]u8 { - var response: nostr.nwc.Response = .{ .result_type = request.method }; +fn handleRequest(request: nwc.Request, params_json: []const u8, lnbits: *LnbitsClient, buf: []u8) ![]u8 { + var response: nwc.Response = .{ .result_type = request.method }; switch (request.params) { .get_balance => { @@ -204,6 +417,10 @@ fn handleRequest(request: nostr.nwc.Request, lnbits: *LnbitsClient, buf: []u8) ! } }; }, .make_invoice => |params| { + if (params.amount == 0 or params.amount % 1000 != 0) { + response.err = .{ .code = .other, .message = "Amount must be a positive whole number of sats" }; + return response.serialize(buf); + } const invoice = lnbits.createInvoice(params.amount, params.description) catch { response.err = .{ .code = .internal, .message = "Failed to create invoice" }; return response.serialize(buf); @@ -215,10 +432,15 @@ fn handleRequest(request: nostr.nwc.Request, lnbits: *LnbitsClient, buf: []u8) ! .payment_hash = invoice.payment_hash, .amount = params.amount, .description = params.description, - .created_at = std.time.timestamp(), + .created_at = nostr.io.timestamp(), } }; }, .pay_invoice => |params| { + if (checkPayInvoice(params_json, params)) |err| { + response.err = err; + return response.serialize(buf); + } + const result = lnbits.payInvoice(params.invoice) catch { response.err = .{ .code = .payment_failed, .message = "Payment failed" }; return response.serialize(buf); @@ -234,7 +456,7 @@ fn handleRequest(request: nostr.nwc.Request, lnbits: *LnbitsClient, buf: []u8) ! response.result = .{ .pay_invoice = .{ .preimage = details.preimage, - .fees_paid = if (details.fee > 0) @intCast(details.fee) else null, + .fees_paid = if (details.fee != 0) @abs(details.fee) else null, } }; }, .lookup_invoice => |params| { @@ -248,15 +470,15 @@ fn handleRequest(request: nostr.nwc.Request, lnbits: *LnbitsClient, buf: []u8) ! return response.serialize(buf); }; - const state: nostr.nwc.TransactionState = if (details.pending) .pending else .settled; + const state: nwc.TransactionState = if (details.pending) .pending else .settled; response.result = .{ .lookup_invoice = .{ .tx_type = if (details.amount > 0) .incoming else .outgoing, .state = state, .invoice = if (details.bolt11.len > 0) details.bolt11 else null, .payment_hash = details.payment_hash, .preimage = if (details.preimage.len > 0) details.preimage else null, - .amount = if (details.amount > 0) @intCast(details.amount) else null, - .fees_paid = if (details.fee > 0) @intCast(details.fee) else null, + .amount = if (details.amount != 0) @abs(details.amount) else null, + .fees_paid = if (details.fee != 0) @abs(details.fee) else null, .description = if (details.memo.len > 0) details.memo else null, } }; }, @@ -268,11 +490,24 @@ fn handleRequest(request: nostr.nwc.Request, lnbits: *LnbitsClient, buf: []u8) ! return response.serialize(buf); } -fn publishResponse(allocator: std.mem.Allocator, config: *Config, relay: *Relay, recipient_pubkey: *const [32]u8, request_id: *const [32]u8, response_json: []const u8) !void { - const encrypted = try nostr.crypto.nip44Encrypt(&config.privkey, recipient_pubkey, response_json, allocator); +/// Signs a kind 23195 response encrypted to the requester with the scheme the +/// request used, tagged with the requester (`p`) and the request id (`e`). +fn buildResponseEvent( + allocator: std.mem.Allocator, + config: *const Config, + recipient_pubkey: *const [32]u8, + request_id: *const [32]u8, + response_json: []const u8, + encryption: nwc.Encryption, + out: []u8, +) ![]u8 { + const encrypted = switch (encryption) { + .nip44_v2 => try nostr.crypto.nip44Encrypt(&config.privkey, recipient_pubkey, response_json, allocator), + .nip04 => try nostr.nip04.encrypt(&config.privkey, recipient_pubkey, response_json, allocator), + }; defer allocator.free(encrypted); - var keypair = nostr.Keypair{ + const keypair = nostr.Keypair{ .secret_key = config.privkey, .public_key = config.pubkey, }; @@ -289,24 +524,313 @@ fn publishResponse(allocator: std.mem.Allocator, config: *Config, relay: *Relay, }; var builder = nostr.EventBuilder{}; - _ = builder.setKind(nostr.nwc.Kind.response).setContent(encrypted).setTags(&tags); + _ = builder.setKind(nwc.Kind.response).setContent(encrypted).setTags(&tags); try builder.sign(&keypair); - var event_buf: [65536]u8 = undefined; - const event_json = try builder.serialize(&event_buf); - - try relay.publish(event_json); - std.log.debug("Published response", .{}); + return builder.serialize(out); } -test "config parsing" { +test { _ = @import("config.zig"); + _ = @import("lnbits.zig"); + _ = @import("relay.zig"); } -test "lnbits client" { - _ = @import("lnbits.zig"); +const testing = std.testing; + +const TestKeys = struct { + wallet: nostr.Keypair, + client: nostr.Keypair, + stranger: nostr.Keypair, + config: Config, + + fn init() !TestKeys { + try nostr.init(); + var keys: TestKeys = .{ + .wallet = nostr.Keypair.generate(), + .client = nostr.Keypair.generate(), + .stranger = nostr.Keypair.generate(), + .config = undefined, + }; + keys.config = .{ + .privkey = keys.wallet.secret_key, + .pubkey = keys.wallet.public_key, + .relay = "ws://127.0.0.1:1", + .client_pubkeys = .empty, + .lnbits_host = "http://127.0.0.1:1", + .lnbits_admin_key = "key", + ._allocated = .empty, + ._allocator = testing.allocator, + }; + try keys.config.client_pubkeys.append(testing.allocator, keys.client.public_key); + return keys; + } + + fn deinit(self: *TestKeys) void { + self.config.deinit(); + nostr.cleanup(); + } +}; + +const SignedEvent = struct { + buf: [8192]u8 = undefined, + json: []const u8 = "", +}; + +fn signRequest(out: *SignedEvent, author: *const nostr.Keypair, tags: []const []const []const u8, created_at: i64, content: []const u8) !void { + var builder = nostr.EventBuilder{}; + _ = builder.setKind(nwc.Kind.request).setContent(content).setTags(tags).setCreatedAt(created_at); + try builder.sign(author); + out.json = try builder.serialize(&out.buf); } -test "relay connection" { - _ = @import("relay.zig"); +fn hexOf(key: *const [32]u8) [64]u8 { + var out: [64]u8 = undefined; + nostr.hex.encode(key, &out); + return out; +} + +test "checkRequest accepts a signed request from an authorized client" { + var keys = try TestKeys.init(); + defer keys.deinit(); + + const now = nostr.io.timestamp(); + const wallet_hex = hexOf(&keys.wallet.public_key); + const tags = [_][]const []const u8{&.{ "p", &wallet_hex }}; + var signed: SignedEvent = .{}; + try signRequest(&signed, &keys.client, &tags, now, "x"); + + var event = try nostr.Event.parseWithAllocator(signed.json, testing.allocator); + defer event.deinit(); + try checkRequest(&keys.config, &event, now); +} + +test "checkRequest rejects an unauthorized author" { + var keys = try TestKeys.init(); + defer keys.deinit(); + + const now = nostr.io.timestamp(); + const wallet_hex = hexOf(&keys.wallet.public_key); + const tags = [_][]const []const u8{&.{ "p", &wallet_hex }}; + var signed: SignedEvent = .{}; + try signRequest(&signed, &keys.stranger, &tags, now, "x"); + + var event = try nostr.Event.parseWithAllocator(signed.json, testing.allocator); + defer event.deinit(); + try testing.expectError(error.Unauthorized, checkRequest(&keys.config, &event, now)); +} + +test "checkRequest rejects a forged signature" { + var keys = try TestKeys.init(); + defer keys.deinit(); + + const now = nostr.io.timestamp(); + const wallet_hex = hexOf(&keys.wallet.public_key); + const tags = [_][]const []const u8{&.{ "p", &wallet_hex }}; + var signed: SignedEvent = .{}; + try signRequest(&signed, &keys.client, &tags, now, "x"); + + // Same content and author, different created_at: id and sig no longer match. + const original = try testing.allocator.dupe(u8, signed.json); + defer testing.allocator.free(original); + var created_buf: [20]u8 = undefined; + const created = try std.fmt.bufPrint(&created_buf, "{d}", .{now}); + var bumped_buf: [20]u8 = undefined; + const bumped = try std.fmt.bufPrint(&bumped_buf, "{d}", .{now - 1}); + const tampered = try std.mem.replaceOwned(u8, testing.allocator, original, created, bumped); + defer testing.allocator.free(tampered); + + var event = try nostr.Event.parseWithAllocator(tampered, testing.allocator); + defer event.deinit(); + try testing.expectError(error.InvalidSignature, checkRequest(&keys.config, &event, now)); +} + +test "checkRequest rejects requests not addressed to this wallet" { + var keys = try TestKeys.init(); + defer keys.deinit(); + + const now = nostr.io.timestamp(); + const other_hex = hexOf(&keys.stranger.public_key); + const tags = [_][]const []const u8{&.{ "p", &other_hex }}; + var signed: SignedEvent = .{}; + try signRequest(&signed, &keys.client, &tags, now, "x"); + + var event = try nostr.Event.parseWithAllocator(signed.json, testing.allocator); + defer event.deinit(); + try testing.expectError(error.NotAddressedToUs, checkRequest(&keys.config, &event, now)); +} + +test "checkRequest rejects expired, malformed-expiration and stale requests" { + var keys = try TestKeys.init(); + defer keys.deinit(); + + const now = nostr.io.timestamp(); + const wallet_hex = hexOf(&keys.wallet.public_key); + + var past_buf: [20]u8 = undefined; + const past = try std.fmt.bufPrint(&past_buf, "{d}", .{now - 1}); + const expired_tags = [_][]const []const u8{ &.{ "p", &wallet_hex }, &.{ "expiration", past } }; + var expired: SignedEvent = .{}; + try signRequest(&expired, &keys.client, &expired_tags, now, "x"); + var expired_event = try nostr.Event.parseWithAllocator(expired.json, testing.allocator); + defer expired_event.deinit(); + try testing.expectError(error.Expired, checkRequest(&keys.config, &expired_event, now)); + + const bad_tags = [_][]const []const u8{ &.{ "p", &wallet_hex }, &.{ "expiration", "soon" } }; + var bad: SignedEvent = .{}; + try signRequest(&bad, &keys.client, &bad_tags, now, "x"); + var bad_event = try nostr.Event.parseWithAllocator(bad.json, testing.allocator); + defer bad_event.deinit(); + try testing.expectError(error.Expired, checkRequest(&keys.config, &bad_event, now)); + + var future_buf: [20]u8 = undefined; + const future = try std.fmt.bufPrint(&future_buf, "{d}", .{now + 60}); + const live_tags = [_][]const []const u8{ &.{ "p", &wallet_hex }, &.{ "expiration", future } }; + var stale: SignedEvent = .{}; + try signRequest(&stale, &keys.client, &live_tags, now - max_request_age_s - 1, "x"); + var stale_event = try nostr.Event.parseWithAllocator(stale.json, testing.allocator); + defer stale_event.deinit(); + try testing.expectError(error.Stale, checkRequest(&keys.config, &stale_event, now)); + + var live: SignedEvent = .{}; + try signRequest(&live, &keys.client, &live_tags, now, "x"); + var live_event = try nostr.Event.parseWithAllocator(live.json, testing.allocator); + defer live_event.deinit(); + try checkRequest(&keys.config, &live_event, now); +} + +test "requestEncryption follows the NIP-47 encryption tag" { + var keys = try TestKeys.init(); + defer keys.deinit(); + const now = nostr.io.timestamp(); + + const cases = [_]struct { tags: []const []const []const u8, want: ?nwc.Encryption }{ + .{ .tags = &.{&.{ "encryption", "nip44_v2" }}, .want = .nip44_v2 }, + .{ .tags = &.{&.{ "encryption", "nip04" }}, .want = .nip04 }, + .{ .tags = &.{}, .want = .nip04 }, + .{ .tags = &.{&.{ "encryption", "nip99" }}, .want = null }, + .{ .tags = &.{ &.{ "encryption", "nip44_v2" }, &.{ "encryption", "nip04" } }, .want = null }, + }; + for (cases) |case| { + var signed: SignedEvent = .{}; + try signRequest(&signed, &keys.client, case.tags, now, "x"); + var event = try nostr.Event.parseWithAllocator(signed.json, testing.allocator); + defer event.deinit(); + if (case.want) |want| { + try testing.expectEqual(want, try requestEncryption(&event)); + } else { + try testing.expectError(error.UnsupportedEncryption, requestEncryption(&event)); + } + } +} + +test "ReplayGuard rejects a repeated id and forgets expired ones" { + var guard: ReplayGuard = .{}; + defer guard.deinit(testing.allocator); + + const id: [32]u8 = @splat(7); + const other: [32]u8 = @splat(8); + try testing.expect(try guard.firstSeen(testing.allocator, &id, 1000, 1000)); + try testing.expect(!try guard.firstSeen(testing.allocator, &id, 1000, 1000 + max_request_age_s)); + try testing.expect(try guard.firstSeen(testing.allocator, &other, 1000, 1000)); + try testing.expect(try guard.firstSeen(testing.allocator, &id, 1000, 1001 + max_request_age_s)); +} + +test "bolt11AmountMsat reads the human-readable amount" { + try testing.expectEqual(@as(?u64, 250_000_000), try bolt11AmountMsat("lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypq")); + try testing.expectEqual(@as(?u64, 5_000), try bolt11AmountMsat("lnbc50n1pdummy")); + try testing.expectEqual(@as(?u64, 1), try bolt11AmountMsat("lnbcrt10p1pdummy")); + try testing.expectEqual(@as(?u64, 200_000_000_000), try bolt11AmountMsat("LNTB21PDUMMY")); + try testing.expectEqual(@as(?u64, null), try bolt11AmountMsat("lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypq")); + try testing.expectError(error.InvalidInvoice, bolt11AmountMsat("lnbc15p1pdummy")); + try testing.expectError(error.InvalidInvoice, bolt11AmountMsat("lnbc025u1pdummy")); + try testing.expectError(error.InvalidInvoice, bolt11AmountMsat("lnbc25x1pdummy")); + try testing.expectError(error.InvalidInvoice, bolt11AmountMsat("bc25u1pdummy")); + try testing.expectError(error.InvalidInvoice, bolt11AmountMsat("lnbc99999999999999999991pdummy")); +} + +test "checkPayInvoice rejects confusable amounts" { + const invoice = "lnbc50n1pdummy"; + const ok = nwc.Request.parseJson("{\"method\":\"pay_invoice\",\"params\":{\"invoice\":\"lnbc50n1pdummy\"}}").?; + try testing.expect(checkPayInvoice("{\"invoice\":\"lnbc50n1pdummy\"}", ok.params.pay_invoice) == null); + + const matching = "{\"invoice\":\"lnbc50n1pdummy\",\"amount\":5000}"; + try testing.expect(checkPayInvoice(matching, .{ .invoice = invoice, .amount = 5000 }) == null); + + const mismatched = "{\"invoice\":\"lnbc50n1pdummy\",\"amount\":6000}"; + try testing.expectEqualStrings("Amount does not match invoice", checkPayInvoice(mismatched, .{ .invoice = invoice, .amount = 6000 }).?.message); + + const req = nwc.Request.parseJson("{\"method\":\"pay_invoice\",\"params\":{\"invoice\":\"lnbc50n1pdummy\",\"amount\":\"5000\"}}").?; + try testing.expectEqual(@as(?u64, null), req.params.pay_invoice.amount); + try testing.expectEqualStrings("Invalid amount", checkPayInvoice("{\"invoice\":\"lnbc50n1pdummy\",\"amount\":\"5000\"}", req.params.pay_invoice).?.message); + + try testing.expectEqual(nwc.ErrorCode.not_implemented, checkPayInvoice("{\"invoice\":\"lnbc1pdummy\",\"amount\":5000}", .{ .invoice = "lnbc1pdummy", .amount = 5000 }).?.code); + try testing.expectEqualStrings("Invalid invoice", checkPayInvoice("{}", .{ .invoice = "lnbc50n1p\\\"x" }).?.message); +} + +test "serializeInvalidRequest answers unknown methods and bad params" { + var buf: [512]u8 = undefined; + try testing.expectEqualStrings( + "{\"result_type\":\"sign_message\",\"error\":{\"code\":\"NOT_IMPLEMENTED\",\"message\":\"Method not supported\"},\"result\":null}", + serializeInvalidRequest("{\"method\":\"sign_message\",\"params\":{}}", &buf).?, + ); + + try testing.expect(nwc.Request.parseJson("{\"method\":\"pay_invoice\",\"params\":{}}") == null); + const bad = serializeInvalidRequest("{\"method\":\"pay_invoice\",\"params\":{}}", &buf).?; + const parsed = nwc.Response.parseJson(bad).?; + try testing.expectEqual(nwc.Method.pay_invoice, parsed.result_type); + try testing.expectEqual(nwc.ErrorCode.other, parsed.err.?.code); + + try testing.expect(serializeInvalidRequest("{\"params\":{}}", &buf) == null); +} + +test "buildResponseEvent encrypts to the requester and tags p and e" { + var keys = try TestKeys.init(); + defer keys.deinit(); + + const request_id: [32]u8 = @splat(0xab); + const response_json = "{\"result_type\":\"get_balance\",\"error\":null,\"result\":{\"balance\":1000}}"; + + for ([_]nwc.Encryption{ .nip44_v2, .nip04 }) |encryption| { + var out: [16384]u8 = undefined; + const json = try buildResponseEvent(testing.allocator, &keys.config, &keys.client.public_key, &request_id, response_json, encryption, &out); + + var event = try nostr.Event.parseWithAllocator(json, testing.allocator); + defer event.deinit(); + try event.validate(); + try testing.expectEqual(nwc.Kind.response, event.kind()); + try testing.expectEqualSlices(u8, &keys.wallet.public_key, event.pubkey()); + + var tags = utils.TagIterator.init(event.raw_json, "tags").?; + const p = tags.next().?; + try testing.expectEqualStrings("p", p.name); + try testing.expectEqualStrings(&hexOf(&keys.client.public_key), p.value); + const e = tags.next().?; + try testing.expectEqualStrings("e", e.name); + try testing.expectEqualStrings(&hexOf(&request_id), e.value); + + const plain = switch (encryption) { + .nip44_v2 => try nostr.crypto.nip44Decrypt(&keys.client.secret_key, &keys.wallet.public_key, event.content(), testing.allocator), + .nip04 => try nostr.nip04.decrypt(&keys.client.secret_key, &keys.wallet.public_key, event.content(), testing.allocator), + }; + defer testing.allocator.free(plain); + try testing.expectEqualStrings(response_json, plain); + } +} + +test "handleRequest rejects invalid make_invoice amounts without calling LNbits" { + var lnbits = LnbitsClient.init(testing.allocator, testing.io, "http://127.0.0.1:1", "key"); + defer lnbits.deinit(); + + var buf: [1024]u8 = undefined; + for ([_]u64{ 0, 1500, 999 }) |amount| { + const request = nwc.Request{ .method = .make_invoice, .params = .{ .make_invoice = .{ .amount = amount } } }; + const json = try handleRequest(request, "{}", &lnbits, &buf); + const parsed = nwc.Response.parseJson(json).?; + try testing.expectEqual(nwc.ErrorCode.other, parsed.err.?.code); + } + + const pay = nwc.Request{ .method = .pay_invoice, .params = .{ .pay_invoice = .{ .invoice = "lnbc50n1pdummy", .amount = 1 } } }; + const pay_json = try handleRequest(pay, "{\"invoice\":\"lnbc50n1pdummy\",\"amount\":1}", &lnbits, &buf); + try testing.expectEqualStrings("Amount does not match invoice", nwc.Response.parseJson(pay_json).?.err.?.message); } diff --git a/src/relay.zig b/src/relay.zig index 35b8858..dce1342 100644 --- a/src/relay.zig +++ b/src/relay.zig @@ -1,6 +1,7 @@ const std = @import("std"); const nostr = @import("nostr"); const ws = nostr.ws; +const utils = nostr.utils; const log = std.log.scoped(.relay); @@ -36,11 +37,10 @@ pub const Relay = struct { client: ws.Client, pub fn connect(allocator: std.mem.Allocator, url: []const u8) !Relay { - var client = ws.Client.connect(allocator, url) catch |err| { + const client = ws.Client.connect(allocator, url) catch |err| { log.err("WebSocket connection failed: {}", .{err}); return RelayError.ConnectionFailed; }; - errdefer client.close(); return .{ .allocator = allocator, @@ -53,44 +53,25 @@ pub const Relay = struct { self.client.close(); } + pub fn socket(self: *const Relay) std.posix.fd_t { + return self.client.tcp_stream.socket.handle; + } + pub fn send(self: *Relay, data: []const u8) !void { self.client.sendText(data) catch return RelayError.SendFailed; } - pub fn receive(self: *Relay) !?Message { + pub fn receive(self: *Relay) !Message { const msg = self.client.recvMessage() catch |err| { - if (err == error.EndOfStream or err == error.ConnectionResetByPeer) { - return RelayError.Closed; - } - return null; - }; - - const result = parseMessage(msg.payload, self.allocator) catch |err| { - msg.deinit(); - return err; + log.warn("Receive failed: {}", .{err}); + return RelayError.Closed; }; - msg.deinit(); - return result; + defer msg.deinit(); + return parseMessage(msg.payload, self.allocator); } pub fn freeMessage(self: *Relay, message: *Message) void { - switch (message.*) { - .event => |e| { - self.allocator.free(e.subscription_id); - self.allocator.free(e.event_json); - }, - .ok => |o| { - self.allocator.free(o.event_id); - self.allocator.free(o.message); - }, - .eose => |s| self.allocator.free(s), - .notice => |s| self.allocator.free(s), - .closed => |c| { - self.allocator.free(c.subscription_id); - self.allocator.free(c.message); - }, - .unknown => {}, - } + freeMessageWith(self.allocator, message); } pub fn publish(self: *Relay, event_json: []const u8) !void { @@ -112,122 +93,127 @@ pub const Relay = struct { } }; -fn parseMessage(data: []const u8, allocator: std.mem.Allocator) !Message { - if (data.len < 5) return .unknown; - if (data[0] != '[') return .unknown; +fn freeMessageWith(allocator: std.mem.Allocator, message: *Message) void { + switch (message.*) { + .event => |e| { + allocator.free(e.subscription_id); + allocator.free(e.event_json); + }, + .ok => |o| { + allocator.free(o.event_id); + allocator.free(o.message); + }, + .eose => |s| allocator.free(s), + .notice => |s| allocator.free(s), + .closed => |c| { + allocator.free(c.subscription_id); + allocator.free(c.message); + }, + .unknown => {}, + } +} + +fn stringElement(data: []const u8, index: usize) ?[]const u8 { + const elem = utils.findArrayElement(data, index) orelse return null; + const trimmed = std.mem.trim(u8, elem, " \t\r\n"); + if (trimmed.len < 2 or trimmed[0] != '"' or trimmed[trimmed.len - 1] != '"') return null; + return trimmed[1 .. trimmed.len - 1]; +} - const type_start = std.mem.indexOf(u8, data, "\"") orelse return .unknown; - const type_end = std.mem.indexOfPos(u8, data, type_start + 1, "\"") orelse return .unknown; - const msg_type = data[type_start + 1 .. type_end]; +fn parseMessage(data: []const u8, allocator: std.mem.Allocator) !Message { + const trimmed = std.mem.trim(u8, data, " \t\r\n"); + if (trimmed.len == 0 or trimmed[0] != '[') return .unknown; + const msg_type = stringElement(trimmed, 0) orelse return .unknown; if (std.mem.eql(u8, msg_type, "EVENT")) { - var pos = type_end + 1; - while (pos < data.len and (data[pos] == ',' or data[pos] == ' ' or data[pos] == '"')) : (pos += 1) {} - const sub_start = pos; - while (pos < data.len and data[pos] != '"') : (pos += 1) {} - const sub_id = data[sub_start..pos]; - - pos += 1; - while (pos < data.len and (data[pos] == ',' or data[pos] == ' ')) : (pos += 1) {} - - if (pos < data.len and data[pos] == '{') { - var depth: usize = 0; - const event_start = pos; - while (pos < data.len) : (pos += 1) { - if (data[pos] == '{') depth += 1; - if (data[pos] == '}') { - depth -= 1; - if (depth == 0) { - const sub_id_copy = try allocator.dupe(u8, sub_id); - errdefer allocator.free(sub_id_copy); - const event_json_copy = try allocator.dupe(u8, data[event_start .. pos + 1]); - return .{ .event = .{ - .subscription_id = sub_id_copy, - .event_json = event_json_copy, - } }; - } - } - } - } - return .unknown; + const sub_id = stringElement(trimmed, 1) orelse return .unknown; + const raw_event = utils.findArrayElement(trimmed, 2) orelse return .unknown; + const event_json = std.mem.trim(u8, raw_event, " \t\r\n"); + if (event_json.len == 0 or event_json[0] != '{') return .unknown; + if (utils.skipJsonValue(event_json, 0) != event_json.len) return .unknown; + + const sub_id_copy = try allocator.dupe(u8, sub_id); + errdefer allocator.free(sub_id_copy); + return .{ .event = .{ + .subscription_id = sub_id_copy, + .event_json = try allocator.dupe(u8, event_json), + } }; } if (std.mem.eql(u8, msg_type, "OK")) { - var pos = type_end + 1; - while (pos < data.len and (data[pos] == ',' or data[pos] == ' ' or data[pos] == '"')) : (pos += 1) {} - const id_start = pos; - while (pos < data.len and data[pos] != '"') : (pos += 1) {} - const event_id = data[id_start..pos]; - - while (pos < data.len and data[pos] != 't' and data[pos] != 'f') : (pos += 1) {} - const success = pos < data.len and data[pos] == 't'; - - var message: []const u8 = ""; - if (std.mem.lastIndexOf(u8, data, "\"")) |last_quote| { - if (lastIndexOfBefore(data, '"', last_quote)) |second_last| { - message = data[second_last + 1 .. last_quote]; - } - } + const event_id = stringElement(trimmed, 1) orelse return .unknown; + const success_raw = utils.findArrayElement(trimmed, 2) orelse return .unknown; + const message = stringElement(trimmed, 3) orelse ""; const event_id_copy = try allocator.dupe(u8, event_id); errdefer allocator.free(event_id_copy); - const message_copy = try allocator.dupe(u8, message); return .{ .ok = .{ .event_id = event_id_copy, - .success = success, - .message = message_copy, + .success = std.mem.eql(u8, std.mem.trim(u8, success_raw, " \t\r\n"), "true"), + .message = try allocator.dupe(u8, message), } }; } if (std.mem.eql(u8, msg_type, "EOSE")) { - var pos = type_end + 1; - while (pos < data.len and (data[pos] == ',' or data[pos] == ' ' or data[pos] == '"')) : (pos += 1) {} - const sub_start = pos; - while (pos < data.len and data[pos] != '"') : (pos += 1) {} - return .{ .eose = try allocator.dupe(u8, data[sub_start..pos]) }; + return .{ .eose = try allocator.dupe(u8, stringElement(trimmed, 1) orelse return .unknown) }; } if (std.mem.eql(u8, msg_type, "NOTICE")) { - if (std.mem.lastIndexOf(u8, data, "\"")) |last_quote| { - if (lastIndexOfBefore(data, '"', last_quote)) |second_last| { - return .{ .notice = try allocator.dupe(u8, data[second_last + 1 .. last_quote]) }; - } - } - return .unknown; + return .{ .notice = try allocator.dupe(u8, stringElement(trimmed, 1) orelse return .unknown) }; } if (std.mem.eql(u8, msg_type, "CLOSED")) { - var pos = type_end + 1; - while (pos < data.len and (data[pos] == ',' or data[pos] == ' ' or data[pos] == '"')) : (pos += 1) {} - const sub_start = pos; - while (pos < data.len and data[pos] != '"') : (pos += 1) {} - const sub_id = data[sub_start..pos]; - - var message: []const u8 = ""; - if (std.mem.lastIndexOf(u8, data, "\"")) |last_quote| { - if (lastIndexOfBefore(data, '"', last_quote)) |second_last| { - message = data[second_last + 1 .. last_quote]; - } - } + const sub_id = stringElement(trimmed, 1) orelse return .unknown; + const message = stringElement(trimmed, 2) orelse ""; const sub_id_copy = try allocator.dupe(u8, sub_id); errdefer allocator.free(sub_id_copy); - const message_copy = try allocator.dupe(u8, message); return .{ .closed = .{ .subscription_id = sub_id_copy, - .message = message_copy, + .message = try allocator.dupe(u8, message), } }; } return .unknown; } -fn lastIndexOfBefore(data: []const u8, needle: u8, before: usize) ?usize { - if (before == 0) return null; - var i = before - 1; - while (true) { - if (data[i] == needle) return i; - if (i == 0) return null; - i -= 1; - } +test "parse EVENT keeps braces inside strings" { + const allocator = std.testing.allocator; + const data = + \\["EVENT","nwc",{"id":"x","tags":[["t","}{"]],"content":"a}b"}] + ; + var msg = try parseMessage(data, allocator); + defer freeMessageWith(allocator, &msg); + try std.testing.expectEqualStrings("nwc", msg.event.subscription_id); + try std.testing.expectEqualStrings( + \\{"id":"x","tags":[["t","}{"]],"content":"a}b"} + , msg.event.event_json); +} + +test "parse OK, EOSE, NOTICE and CLOSED" { + const allocator = std.testing.allocator; + + var ok = try parseMessage("[\"OK\",\"abc\",false,\"blocked: no\"]", allocator); + defer freeMessageWith(allocator, &ok); + try std.testing.expect(!ok.ok.success); + try std.testing.expectEqualStrings("blocked: no", ok.ok.message); + + var eose = try parseMessage("[\"EOSE\",\"nwc\"]", allocator); + defer freeMessageWith(allocator, &eose); + try std.testing.expectEqualStrings("nwc", eose.eose); + + var notice = try parseMessage("[\"NOTICE\",\"hi\"]", allocator); + defer freeMessageWith(allocator, ¬ice); + try std.testing.expectEqualStrings("hi", notice.notice); + + var closed = try parseMessage("[\"CLOSED\",\"nwc\",\"error: x\"]", allocator); + defer freeMessageWith(allocator, &closed); + try std.testing.expectEqualStrings("error: x", closed.closed.message); +} + +test "malformed EVENT is unknown" { + const allocator = std.testing.allocator; + var msg = try parseMessage("[\"EVENT\",\"nwc\",{\"id\":\"x\"", allocator); + defer freeMessageWith(allocator, &msg); + try std.testing.expect(msg == .unknown); }