nwc-ruby 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d541ad9af31629ad26492d8da61d56b0a63980cc8597a460cca9949df56f382d
4
- data.tar.gz: 8d48fbbe532bdcf56d889f68f9d7878fa2958381c1ea285fb572aebb70d17d7e
3
+ metadata.gz: 33006d633764a07adc713f4e5e6fe49a5108927286258abf0d976076a3f125d1
4
+ data.tar.gz: 4727a1b8968c741a2ec2feb2c4f576b4afe8d4df2066f09ddf26f6eb53ed42a1
5
5
  SHA512:
6
- metadata.gz: bbc5e4a3efdac9b24a4a2c5fc183f3945ba9987fd5312c78a7e8c4ba8cff625af5c08521094cc10101fd24a0bef5f35261eb29720d533d7e0167a236ee59d106
7
- data.tar.gz: fb2c7a1ed8d7785a886b38e250432466354b257cef1e4c8552ef24c493c304dbd446bf4c17e57debcce8bc6875666686c16f901d29474ef08541194e6e2a97f3
6
+ metadata.gz: 6fc9796e80a92b9e1d255ee2fdafb85d5171c7a74a6d25b522ddebf2114620081775a7a21d409ff6209a2d522e02451d6f3057627dd07e2f7091bbba779639a4
7
+ data.tar.gz: '087888d35e9b84ad81a8ab1f97ee1914a75b2d32a3632c62972bda323225d83eb082fb3e02df469138a980795e3e62692ba9877a0bb398ed228d6a9337f622bd'
data/CHANGELOG.md CHANGED
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.4] — 2026-07-17
11
+
12
+ ### Security
13
+
14
+ - **Event ids are now recomputed before the signature is trusted.**
15
+ `Event#valid_signature?` verified the signature against the `id` supplied by
16
+ the relay without checking that the `id` actually described the event body.
17
+ Because a Nostr signature only ever commits to the `id`, nothing bound that
18
+ `id` to the event it arrived with: a relay could lift a genuine
19
+ `(id, sig, pubkey)` triple from any real wallet event and re-attach it to
20
+ arbitrary `content`, `tags`, `kind` or `created_at`, and the result verified.
21
+ The id is now recomputed from the canonical serialization first, via the new
22
+ `Event#valid_id?`. Reported by @riccardobl in
23
+ [#1](https://github.com/MegalithicBTC/nwc-ruby/issues/1).
24
+
25
+ The sharpest edge was the kind 13194 info event, whose content and tags are
26
+ plaintext: stripping its `encryption` tag silently downgraded every
27
+ subsequent request from NIP-44 v2 to NIP-04, since the spec reads an absent
28
+ tag as "nip04 only".
29
+
30
+ - **Responses are now bound to the request that asked for them.** `call` trusted
31
+ the relay to honour the `#e` REQ filter and never checked the response's `e`
32
+ tag itself, so a relay could replay an older but genuine response — a past
33
+ "payment succeeded" answering a fresh `pay_invoice`, for instance.
34
+
35
+ - **`fetch_info` now verifies the event author.** It previously checked only
36
+ `valid_signature?` and never compared the pubkey to the wallet's, so a relay
37
+ could sign an info event with its own key and have it accepted — forcing the
38
+ encryption downgrade above without needing to tamper with anything.
39
+
40
+ ### Fixed
41
+
42
+ - The notification listener no longer tears down when a relay delivers an event
43
+ of an unexpected kind. `Notification.parse` raises `ArgumentError` on a
44
+ non-notification kind, but only `EncryptionError` was rescued, so a relay
45
+ echoing a kind 23195 response onto the subscription killed the listener.
46
+ - A rejected info event no longer ends the `fetch_info` read loop, so one junk
47
+ event cannot deny service while a genuine info event is still inbound.
48
+ - Corrected `source_code_uri` and `changelog_uri` in the gemspec, which pointed
49
+ at a `main` branch that does not exist — both links 404'd from the RubyGems
50
+ page. This repo uses `master`; `PUBLISHING.md` has been corrected to match.
51
+ - `PUBLISHING.md` no longer claims releases publish automatically on tag. There
52
+ is no `release.yml` in this repo — only `ci.yml`, which tests but never
53
+ publishes — so releases are manual.
54
+
55
+ ### Added
56
+
57
+ - Documented a gentle `lookup_invoice` backup polling cadence for invoices so
58
+ apps can recover from missed notifications without getting rate limited by the
59
+ backing NWC service.
60
+
61
+ ## [0.2.3] — 2026-04-23
62
+
10
63
  ### Fixed
11
64
 
12
65
  - Silenced the noisy `Async::Task` warn log ("Task may have ended with
data/README.md CHANGED
@@ -224,6 +224,67 @@ to keep middleboxes from idle-closing the socket, reconnects with capped
224
224
  exponential backoff on failure, and force-recycles the connection every 5 minutes
225
225
  as a belt-and-suspenders check against silently dead TCP streams.
226
226
 
227
+ #### Polling as a backup
228
+
229
+ Notifications should be your primary settlement path, but production checkout
230
+ flows should also run a small `lookup_invoice` poller after creating an invoice.
231
+ Treat it as a backup for missed notifications, relay interruptions, app restarts,
232
+ or wallet services that deliver notifications slowly.
233
+
234
+ Do not poll every second until the invoice expires. Backing NWC services often
235
+ enforce rate limits, and aggressive polling can get your app throttled right
236
+ when a customer is trying to pay. Start soon after invoice creation, then poll
237
+ less often as the invoice gets older, and stop when it is paid or expired.
238
+
239
+ One reasonable cadence:
240
+
241
+ ```ruby
242
+ def next_lookup_delay(elapsed)
243
+ return 3.seconds if elapsed < 2.minutes
244
+ return 6.seconds if elapsed < 5.minutes
245
+ 12.seconds
246
+ end
247
+
248
+ invoice = client.make_invoice(amount: 10_000, description: "coffee")
249
+ record = Invoice.create!(
250
+ payment_hash: invoice["payment_hash"],
251
+ bolt11: invoice["invoice"],
252
+ state: "pending",
253
+ expires_at: Time.current + 10.minutes,
254
+ next_lookup_at: Time.current + 3.seconds
255
+ )
256
+
257
+ # In a continuously running rake task / worker:
258
+ loop do
259
+ Invoice.where(state: "pending")
260
+ .where("next_lookup_at <= ?", Time.current)
261
+ .find_each do |inv|
262
+ if inv.expires_at <= Time.current
263
+ inv.update!(state: "expired", next_lookup_at: nil)
264
+ next
265
+ end
266
+
267
+ response = client.lookup_invoice(payment_hash: inv.payment_hash)
268
+ settled = response["settled"] || response["paid"] ||
269
+ response["state"] == "settled" || response["state"] == "SETTLED" ||
270
+ response["settled_at"]
271
+
272
+ if settled
273
+ inv.update!(state: "paid", paid_at: Time.current, next_lookup_at: nil)
274
+ else
275
+ elapsed = Time.current - inv.created_at
276
+ inv.update!(next_lookup_at: Time.current + next_lookup_delay(elapsed))
277
+ end
278
+ end
279
+
280
+ sleep 2
281
+ end
282
+ ```
283
+
284
+ Keep the notification handler and the poller idempotent. Either one might mark
285
+ the invoice paid first, and the other should see an already-paid row and do
286
+ nothing.
287
+
227
288
  #### Resuming after a restart
228
289
 
229
290
  Persist the `created_at` of the last notification you processed and pass it as
@@ -187,8 +187,10 @@ module NwcRuby
187
187
 
188
188
  conn.on_event do |_sub, event_hash|
189
189
  event = Event.from_hash(event_hash)
190
- next unless event.valid_signature?
191
- next unless event.pubkey == @connection_string.wallet_pubkey
190
+ next unless authentic?(event)
191
+ # Notification.parse raises on any other kind, which would tear down
192
+ # the listener; the relay chooses what it sends us, not the filter.
193
+ next unless kinds.include?(event.kind)
192
194
 
193
195
  # Advance the poll watermark so we don't re-fetch old events.
194
196
  last_seen_at = event.created_at if event.created_at && event.created_at > last_seen_at
@@ -219,6 +221,33 @@ module NwcRuby
219
221
 
220
222
  private
221
223
 
224
+ # Every event arriving from a relay must clear this before it is parsed.
225
+ # `valid_signature?` recomputes the id, so a passing event is byte-for-byte
226
+ # what the wallet signed — which is what makes the kind and tag checks
227
+ # below meaningful.
228
+ def authentic?(event)
229
+ event.valid_signature? && event.pubkey == @connection_string.wallet_pubkey
230
+ end
231
+
232
+ # A relay is not trusted to honour the REQ filter it was sent, so the
233
+ # response is bound to the request here. Without this an older but genuine
234
+ # response (say, a past "payment succeeded") can be replayed against a new
235
+ # request.
236
+ def response_to?(event, request_id)
237
+ e_tag = event.tags.find { |t| t[0] == 'e' }
238
+ !e_tag.nil? && e_tag[1] == request_id
239
+ end
240
+
241
+ # Advisory only — the relay decides what it actually sends, so `fetch_info`
242
+ # re-checks the author and kind on whatever comes back.
243
+ def info_filter
244
+ {
245
+ 'authors' => [@connection_string.wallet_pubkey],
246
+ 'kinds' => [NIP47::Methods::KIND_INFO],
247
+ 'limit' => 1
248
+ }
249
+ end
250
+
222
251
  # rubocop:disable Metrics/MethodLength
223
252
  def call(method, params)
224
253
  ensure_supports!(method)
@@ -260,8 +289,9 @@ module NwcRuby
260
289
  next unless parsed[0] == 'EVENT' && parsed[1] == sub_id
261
290
 
262
291
  event = Event.from_hash(parsed[2])
263
- next unless event.valid_signature?
264
- next unless event.pubkey == @connection_string.wallet_pubkey
292
+ next unless authentic?(event)
293
+ next unless event.kind == NIP47::Methods::KIND_RESPONSE
294
+ next unless response_to?(event, request_event.id)
265
295
 
266
296
  result = NIP47::Response.parse(event, @connection_string.secret, @connection_string.wallet_pubkey)
267
297
  break
@@ -290,11 +320,7 @@ module NwcRuby
290
320
  Async do
291
321
  Async::WebSocket::Client.connect(endpoint) do |conn|
292
322
  sub_id = "info-#{SecureRandom.hex(4)}"
293
- conn.write(Protocol::WebSocket::TextMessage.generate(['REQ', sub_id, {
294
- 'authors' => [@connection_string.wallet_pubkey],
295
- 'kinds' => [NIP47::Methods::KIND_INFO],
296
- 'limit' => 1
297
- }]))
323
+ conn.write(Protocol::WebSocket::TextMessage.generate(['REQ', sub_id, info_filter]))
298
324
  conn.flush
299
325
 
300
326
  while (msg = conn.read)
@@ -307,8 +333,12 @@ module NwcRuby
307
333
  end
308
334
 
309
335
  if parsed[0] == 'EVENT' && parsed[1] == sub_id
310
- event = Event.from_hash(parsed[2])
311
- result = NIP47::Info.parse(event) if event.valid_signature?
336
+ event = Event.from_hash(parsed[2])
337
+ # A rejected event must not end the read: a genuine info event
338
+ # may still follow, and the deadline above bounds the wait.
339
+ next unless authentic?(event) && event.kind == NIP47::Methods::KIND_INFO
340
+
341
+ result = NIP47::Info.parse(event)
312
342
  break
313
343
  elsif parsed[0] == 'EOSE' && parsed[1] == sub_id
314
344
  break
@@ -50,8 +50,20 @@ module NwcRuby
50
50
  self
51
51
  end
52
52
 
53
+ # True when the id is the SHA-256 of this event's canonical serialization.
54
+ def valid_id?
55
+ return false unless @id.is_a?(String)
56
+
57
+ @id == OpenSSL::Digest::SHA256.hexdigest(serialize_for_id)
58
+ end
59
+
60
+ # The signature only commits to the id, so the id must be recomputed from
61
+ # the event body before the signature means anything. Without that check a
62
+ # relay can graft a genuine (id, sig) pair from one event onto arbitrary
63
+ # pubkey/created_at/kind/tags/content and still verify.
53
64
  def valid_signature?
54
65
  return false unless @id && @sig && @pubkey
66
+ return false unless valid_id?
55
67
 
56
68
  digest_bytes = Crypto::Keys.hex_to_bytes(@id)
57
69
  Crypto::Schnorr.verify(digest_bytes, @sig, @pubkey)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NwcRuby
4
- VERSION = '0.2.3'
4
+ VERSION = '0.2.4'
5
5
  end
data/nwc-ruby.gemspec CHANGED
@@ -23,9 +23,9 @@ Gem::Specification.new do |spec|
23
23
  spec.required_ruby_version = '>= 3.2.0'
24
24
 
25
25
  spec.metadata['homepage_uri'] = spec.homepage
26
- spec.metadata['source_code_uri'] = "#{spec.homepage}/tree/main"
26
+ spec.metadata['source_code_uri'] = "#{spec.homepage}/tree/master"
27
27
  spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues"
28
- spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
28
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/master/CHANGELOG.md"
29
29
  spec.metadata['rubygems_mfa_required'] = 'true'
30
30
 
31
31
  spec.files = Dir[
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nwc-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - MegalithicBTC
@@ -204,9 +204,9 @@ licenses:
204
204
  - MIT
205
205
  metadata:
206
206
  homepage_uri: https://github.com/MegalithicBTC/nwc-ruby
207
- source_code_uri: https://github.com/MegalithicBTC/nwc-ruby/tree/main
207
+ source_code_uri: https://github.com/MegalithicBTC/nwc-ruby/tree/master
208
208
  bug_tracker_uri: https://github.com/MegalithicBTC/nwc-ruby/issues
209
- changelog_uri: https://github.com/MegalithicBTC/nwc-ruby/blob/main/CHANGELOG.md
209
+ changelog_uri: https://github.com/MegalithicBTC/nwc-ruby/blob/master/CHANGELOG.md
210
210
  rubygems_mfa_required: 'true'
211
211
  rdoc_options: []
212
212
  require_paths:
@@ -222,7 +222,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
222
222
  - !ruby/object:Gem::Version
223
223
  version: '0'
224
224
  requirements: []
225
- rubygems_version: 3.6.8
225
+ rubygems_version: 3.6.9
226
226
  specification_version: 4
227
227
  summary: Ruby client for Nostr Wallet Connect (NIP-47) with safe long-running relay
228
228
  connections.