nwc-ruby 0.2.0
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 +7 -0
- data/CHANGELOG.md +61 -0
- data/LICENSE +21 -0
- data/README.md +588 -0
- data/lib/nwc-ruby.rb +3 -0
- data/lib/nwc_ruby/client.rb +344 -0
- data/lib/nwc_ruby/connection_string.rb +81 -0
- data/lib/nwc_ruby/crypto/ecdh.rb +54 -0
- data/lib/nwc_ruby/crypto/keys.rb +46 -0
- data/lib/nwc_ruby/crypto/schnorr.rb +38 -0
- data/lib/nwc_ruby/errors.rb +39 -0
- data/lib/nwc_ruby/event.rb +72 -0
- data/lib/nwc_ruby/nip04/cipher.rb +47 -0
- data/lib/nwc_ruby/nip44/cipher.rb +176 -0
- data/lib/nwc_ruby/nip47/info.rb +67 -0
- data/lib/nwc_ruby/nip47/methods.rb +43 -0
- data/lib/nwc_ruby/nip47/notification.rb +50 -0
- data/lib/nwc_ruby/nip47/request.rb +35 -0
- data/lib/nwc_ruby/nip47/response.rb +58 -0
- data/lib/nwc_ruby/test_runner.rb +331 -0
- data/lib/nwc_ruby/transport/relay_connection.rb +240 -0
- data/lib/nwc_ruby/version.rb +5 -0
- data/lib/nwc_ruby.rb +82 -0
- data/nwc-ruby.gemspec +52 -0
- metadata +229 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'async'
|
|
4
|
+
require 'async/http/endpoint'
|
|
5
|
+
require 'async/websocket/client'
|
|
6
|
+
|
|
7
|
+
module NwcRuby
|
|
8
|
+
# The main public API.
|
|
9
|
+
#
|
|
10
|
+
# client = NwcRuby::Client.from_uri(ENV["NWC_URL"])
|
|
11
|
+
#
|
|
12
|
+
# # One-shot request/response (transparently opens a WS, sends, waits, closes):
|
|
13
|
+
# info = client.get_info
|
|
14
|
+
# balance = client.get_balance
|
|
15
|
+
# invoice = client.make_invoice(amount: 1_000, description: "tip")
|
|
16
|
+
#
|
|
17
|
+
# # Long-running listener (transparently heartbeats, reconnects, resumes):
|
|
18
|
+
# client.subscribe_to_notifications do |notification|
|
|
19
|
+
# puts "Got #{notification.amount_msats} msats"
|
|
20
|
+
# end
|
|
21
|
+
class Client
|
|
22
|
+
DEFAULT_TIMEOUT = 30
|
|
23
|
+
|
|
24
|
+
attr_reader :connection_string, :logger
|
|
25
|
+
|
|
26
|
+
def self.from_uri(uri_string, **)
|
|
27
|
+
new(ConnectionString.parse(uri_string), **)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(connection_string, logger: nil, request_timeout: DEFAULT_TIMEOUT)
|
|
31
|
+
@connection_string = connection_string
|
|
32
|
+
@logger = logger || default_logger
|
|
33
|
+
@request_timeout = request_timeout
|
|
34
|
+
@info = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# -- Introspection --------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
# Fetch and cache the kind 13194 info event. This tells us which methods
|
|
40
|
+
# the wallet service supports and which encryption schemes it accepts.
|
|
41
|
+
def info(refresh: false)
|
|
42
|
+
return @info if @info && !refresh
|
|
43
|
+
|
|
44
|
+
@info = fetch_info
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def capabilities
|
|
48
|
+
info.methods
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def read_only?
|
|
52
|
+
info.read_only?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def read_write?
|
|
56
|
+
info.read_write?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# -- NIP-47 methods -------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def pay_invoice(invoice:, amount: nil)
|
|
62
|
+
params = { 'invoice' => invoice }
|
|
63
|
+
params['amount'] = amount if amount
|
|
64
|
+
call(NIP47::Methods::PAY_INVOICE, params)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def multi_pay_invoice(invoices:)
|
|
68
|
+
call(NIP47::Methods::MULTI_PAY_INVOICE, { 'invoices' => invoices })
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def pay_keysend(amount:, pubkey:, preimage: nil, tlv_records: nil)
|
|
72
|
+
params = { 'amount' => amount, 'pubkey' => pubkey }
|
|
73
|
+
params['preimage'] = preimage if preimage
|
|
74
|
+
params['tlv_records'] = tlv_records if tlv_records
|
|
75
|
+
call(NIP47::Methods::PAY_KEYSEND, params)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def multi_pay_keysend(keysends:)
|
|
79
|
+
call(NIP47::Methods::MULTI_PAY_KEYSEND, { 'keysends' => keysends })
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def make_invoice(amount:, description: nil, description_hash: nil, expiry: nil, metadata: nil)
|
|
83
|
+
params = { 'amount' => amount }
|
|
84
|
+
params['description'] = description if description
|
|
85
|
+
params['description_hash'] = description_hash if description_hash
|
|
86
|
+
params['expiry'] = expiry if expiry
|
|
87
|
+
params['metadata'] = metadata if metadata
|
|
88
|
+
call(NIP47::Methods::MAKE_INVOICE, params)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def lookup_invoice(payment_hash: nil, invoice: nil)
|
|
92
|
+
raise ArgumentError, 'lookup_invoice requires payment_hash or invoice' if payment_hash.nil? && invoice.nil?
|
|
93
|
+
|
|
94
|
+
params = {}
|
|
95
|
+
params['payment_hash'] = payment_hash if payment_hash
|
|
96
|
+
params['invoice'] = invoice if invoice
|
|
97
|
+
call(NIP47::Methods::LOOKUP_INVOICE, params)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def list_transactions(from: nil, until_ts: nil, limit: nil, offset: nil, unpaid: nil, type: nil)
|
|
101
|
+
params = {}
|
|
102
|
+
params['from'] = from if from
|
|
103
|
+
params['until'] = until_ts if until_ts
|
|
104
|
+
params['limit'] = limit if limit
|
|
105
|
+
params['offset'] = offset if offset
|
|
106
|
+
params['unpaid'] = unpaid unless unpaid.nil?
|
|
107
|
+
params['type'] = type if type
|
|
108
|
+
call(NIP47::Methods::LIST_TRANSACTIONS, params)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def get_balance
|
|
112
|
+
call(NIP47::Methods::GET_BALANCE, {})
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def get_info
|
|
116
|
+
call(NIP47::Methods::GET_INFO, {})
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def sign_message(message:)
|
|
120
|
+
call(NIP47::Methods::SIGN_MESSAGE, { 'message' => message })
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Stop a running subscribe_to_notifications listener.
|
|
124
|
+
def stop_notifications!
|
|
125
|
+
@notification_connection&.stop!
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# -- Notification listener -----------------------------------------------
|
|
129
|
+
|
|
130
|
+
# Subscribe to payment_received / payment_sent notifications from the
|
|
131
|
+
# wallet service. Blocks forever, handling heartbeat and reconnect.
|
|
132
|
+
#
|
|
133
|
+
# client.subscribe_to_notifications do |notification|
|
|
134
|
+
# case notification.type
|
|
135
|
+
# when "payment_received" then credit_invoice(notification.payment_hash, notification.amount_msats)
|
|
136
|
+
# when "payment_sent" then mark_outbound_settled(notification.payment_hash)
|
|
137
|
+
# end
|
|
138
|
+
# end
|
|
139
|
+
#
|
|
140
|
+
# @param since [Integer] unix timestamp for the `since:` filter on the
|
|
141
|
+
# subscription; defaults to now. Pass the last-seen `created_at` on
|
|
142
|
+
# reconnect to avoid replaying history.
|
|
143
|
+
# @param kinds [Array<Integer>] notification kinds to listen for. Defaults
|
|
144
|
+
# to both NIP-04 (23196) and NIP-44 v2 (23197). The listener dedupes by
|
|
145
|
+
# `payment_hash`, so receiving both is safe.
|
|
146
|
+
def subscribe_to_notifications(since: Time.now.to_i,
|
|
147
|
+
kinds: [NIP47::Methods::KIND_NOTIFICATION_NIP04,
|
|
148
|
+
NIP47::Methods::KIND_NOTIFICATION_NIP44],
|
|
149
|
+
sub_id: "nwc-#{SecureRandom.hex(4)}",
|
|
150
|
+
poll_interval: nil,
|
|
151
|
+
&block)
|
|
152
|
+
raise ArgumentError, 'block required' unless block
|
|
153
|
+
|
|
154
|
+
seen = {}
|
|
155
|
+
last_seen_at = since
|
|
156
|
+
filters = [{
|
|
157
|
+
'authors' => [@connection_string.wallet_pubkey],
|
|
158
|
+
'#p' => [@connection_string.client_pubkey],
|
|
159
|
+
'kinds' => kinds,
|
|
160
|
+
'since' => since
|
|
161
|
+
}]
|
|
162
|
+
|
|
163
|
+
conn = Transport::RelayConnection.new(
|
|
164
|
+
url: @connection_string.relays.first,
|
|
165
|
+
logger: @logger,
|
|
166
|
+
poll_interval: poll_interval
|
|
167
|
+
)
|
|
168
|
+
@notification_connection = conn
|
|
169
|
+
|
|
170
|
+
conn.on_open do |c|
|
|
171
|
+
@logger.info("[nwc] subscribing sub_id=#{sub_id} client_pubkey=#{@connection_string.client_pubkey} wallet_pubkey=#{@connection_string.wallet_pubkey} kinds=#{kinds.inspect} since=#{since}")
|
|
172
|
+
c.send_req(sub_id: sub_id, filters: filters)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Periodic re-subscribe: some relays (e.g. strfry) store ephemeral
|
|
176
|
+
# events temporarily but never push them to existing subscriptions.
|
|
177
|
+
# Re-sending the REQ with the same sub_id forces the relay to return
|
|
178
|
+
# any newly-stored events.
|
|
179
|
+
conn.on_poll do |c|
|
|
180
|
+
filters[0]['since'] = last_seen_at
|
|
181
|
+
@logger.debug("[nwc] re-subscribing sub_id=#{sub_id} since=#{last_seen_at}")
|
|
182
|
+
c.send_req(sub_id: sub_id, filters: filters)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
conn.on_event do |_sub, event_hash|
|
|
186
|
+
event = Event.from_hash(event_hash)
|
|
187
|
+
next unless event.valid_signature?
|
|
188
|
+
next unless event.pubkey == @connection_string.wallet_pubkey
|
|
189
|
+
|
|
190
|
+
# Advance the poll watermark so we don't re-fetch old events.
|
|
191
|
+
last_seen_at = event.created_at if event.created_at && event.created_at > last_seen_at
|
|
192
|
+
|
|
193
|
+
begin
|
|
194
|
+
notification = NIP47::Notification.parse(event, @connection_string.secret, @connection_string.wallet_pubkey)
|
|
195
|
+
rescue EncryptionError => e
|
|
196
|
+
@logger.warn("[nwc] could not decrypt notification: #{e.message}")
|
|
197
|
+
next
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Dedupe: wallets that support both encryption schemes publish both
|
|
201
|
+
# 23196 and 23197 for the same event.
|
|
202
|
+
key = notification.payment_hash || event.id
|
|
203
|
+
next if seen[key]
|
|
204
|
+
|
|
205
|
+
seen[key] = true
|
|
206
|
+
# Primitive GC to keep the hash bounded.
|
|
207
|
+
seen.shift while seen.size > 10_000
|
|
208
|
+
|
|
209
|
+
block.call(notification)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
conn.run!
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# -- Internals ------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
private
|
|
218
|
+
|
|
219
|
+
# rubocop:disable Metrics/MethodLength
|
|
220
|
+
def call(method, params)
|
|
221
|
+
ensure_supports!(method)
|
|
222
|
+
encryption = info.preferred_encryption
|
|
223
|
+
|
|
224
|
+
deadline = Time.now + @request_timeout
|
|
225
|
+
result = nil
|
|
226
|
+
|
|
227
|
+
Async do
|
|
228
|
+
endpoint = Async::HTTP::Endpoint.parse(@connection_string.relays.first, alpn_protocols: ['http/1.1'])
|
|
229
|
+
Async::WebSocket::Client.connect(endpoint) do |conn|
|
|
230
|
+
request_event = NIP47::Request.build(
|
|
231
|
+
method: method,
|
|
232
|
+
params: params,
|
|
233
|
+
client_privkey: @connection_string.secret,
|
|
234
|
+
wallet_pubkey: @connection_string.wallet_pubkey,
|
|
235
|
+
encryption: encryption
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
sub_id = "rsp-#{SecureRandom.hex(4)}"
|
|
239
|
+
conn.write(Protocol::WebSocket::TextMessage.generate(['REQ', sub_id, {
|
|
240
|
+
'authors' => [@connection_string.wallet_pubkey],
|
|
241
|
+
'kinds' => [NIP47::Methods::KIND_RESPONSE],
|
|
242
|
+
'#e' => [request_event.id],
|
|
243
|
+
'#p' => [@connection_string.client_pubkey]
|
|
244
|
+
}]))
|
|
245
|
+
conn.write(Protocol::WebSocket::TextMessage.generate(['EVENT', request_event.to_h]))
|
|
246
|
+
conn.flush
|
|
247
|
+
|
|
248
|
+
while (msg = conn.read)
|
|
249
|
+
break if Time.now > deadline
|
|
250
|
+
|
|
251
|
+
parsed = begin
|
|
252
|
+
JSON.parse(msg.buffer)
|
|
253
|
+
rescue JSON::ParserError
|
|
254
|
+
next
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
next unless parsed[0] == 'EVENT' && parsed[1] == sub_id
|
|
258
|
+
|
|
259
|
+
event = Event.from_hash(parsed[2])
|
|
260
|
+
next unless event.valid_signature?
|
|
261
|
+
next unless event.pubkey == @connection_string.wallet_pubkey
|
|
262
|
+
|
|
263
|
+
result = NIP47::Response.parse(event, @connection_string.secret, @connection_string.wallet_pubkey)
|
|
264
|
+
break
|
|
265
|
+
end
|
|
266
|
+
ensure
|
|
267
|
+
begin
|
|
268
|
+
conn&.close
|
|
269
|
+
rescue StandardError
|
|
270
|
+
nil
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end.wait
|
|
274
|
+
|
|
275
|
+
raise TimeoutError, "no response to #{method} within #{@request_timeout}s" if result.nil?
|
|
276
|
+
raise WalletServiceError.new(result.error_code || 'UNKNOWN', result.error_message || '') unless result.success?
|
|
277
|
+
|
|
278
|
+
result.result
|
|
279
|
+
end
|
|
280
|
+
# rubocop:enable Metrics/MethodLength
|
|
281
|
+
|
|
282
|
+
def fetch_info
|
|
283
|
+
endpoint = Async::HTTP::Endpoint.parse(@connection_string.relays.first, alpn_protocols: ['http/1.1'])
|
|
284
|
+
deadline = Time.now + @request_timeout
|
|
285
|
+
result = nil
|
|
286
|
+
|
|
287
|
+
Async do
|
|
288
|
+
Async::WebSocket::Client.connect(endpoint) do |conn|
|
|
289
|
+
sub_id = "info-#{SecureRandom.hex(4)}"
|
|
290
|
+
conn.write(Protocol::WebSocket::TextMessage.generate(['REQ', sub_id, {
|
|
291
|
+
'authors' => [@connection_string.wallet_pubkey],
|
|
292
|
+
'kinds' => [NIP47::Methods::KIND_INFO],
|
|
293
|
+
'limit' => 1
|
|
294
|
+
}]))
|
|
295
|
+
conn.flush
|
|
296
|
+
|
|
297
|
+
while (msg = conn.read)
|
|
298
|
+
break if Time.now > deadline
|
|
299
|
+
|
|
300
|
+
parsed = begin
|
|
301
|
+
JSON.parse(msg.buffer)
|
|
302
|
+
rescue JSON::ParserError
|
|
303
|
+
next
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
if parsed[0] == 'EVENT' && parsed[1] == sub_id
|
|
307
|
+
event = Event.from_hash(parsed[2])
|
|
308
|
+
result = NIP47::Info.parse(event) if event.valid_signature?
|
|
309
|
+
break
|
|
310
|
+
elsif parsed[0] == 'EOSE' && parsed[1] == sub_id
|
|
311
|
+
break
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
ensure
|
|
315
|
+
begin
|
|
316
|
+
conn&.close
|
|
317
|
+
rescue StandardError
|
|
318
|
+
nil
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end.wait
|
|
322
|
+
|
|
323
|
+
if result.nil?
|
|
324
|
+
raise TransportError,
|
|
325
|
+
"wallet service published no info event (kind 13194) on #{@connection_string.relays.first}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
result
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def ensure_supports!(method)
|
|
332
|
+
return if info.supports?(method)
|
|
333
|
+
|
|
334
|
+
raise UnsupportedMethodError,
|
|
335
|
+
"wallet service does not advertise `#{method}`. Supported: #{info.methods.join(', ')}"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def default_logger
|
|
339
|
+
logger = Logger.new($stdout)
|
|
340
|
+
logger.level = ENV['NWC_LOG_LEVEL'] ? Logger.const_get(ENV['NWC_LOG_LEVEL'].upcase) : Logger::INFO
|
|
341
|
+
logger
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NwcRuby
|
|
4
|
+
# Parses a `nostr+walletconnect://` URI.
|
|
5
|
+
#
|
|
6
|
+
# Canonical form:
|
|
7
|
+
# nostr+walletconnect://<wallet_service_pubkey_hex>?
|
|
8
|
+
# relay=wss://...
|
|
9
|
+
# &secret=<32_byte_hex>
|
|
10
|
+
# &lud16=<optional>
|
|
11
|
+
#
|
|
12
|
+
# `relay` may repeat. `secret` is the CLIENT private key (32 bytes hex); the
|
|
13
|
+
# derived client pubkey is what the wallet encrypts responses to.
|
|
14
|
+
class ConnectionString
|
|
15
|
+
attr_reader :wallet_pubkey, :relays, :secret, :lud16
|
|
16
|
+
|
|
17
|
+
def self.parse(uri_string)
|
|
18
|
+
new(uri_string)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(uri_string)
|
|
22
|
+
raise InvalidConnectionStringError, 'connection string is empty' if uri_string.nil? || uri_string.empty?
|
|
23
|
+
|
|
24
|
+
# The built-in URI parser chokes on `nostr+walletconnect://` because of
|
|
25
|
+
# the `+`, so normalize the scheme before parsing.
|
|
26
|
+
normalized = uri_string.sub(%r{\Anostr\+walletconnect://}, 'https://')
|
|
27
|
+
uri = URI.parse(normalized)
|
|
28
|
+
|
|
29
|
+
unless uri_string.start_with?('nostr+walletconnect://') ||
|
|
30
|
+
uri_string.start_with?('nostrwalletconnect://')
|
|
31
|
+
raise InvalidConnectionStringError,
|
|
32
|
+
'expected nostr+walletconnect:// scheme'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@wallet_pubkey = uri.host&.downcase
|
|
36
|
+
Crypto::Keys.validate_hex32!(@wallet_pubkey, 'wallet service pubkey (host)')
|
|
37
|
+
|
|
38
|
+
params = decode_query(uri.query || '')
|
|
39
|
+
|
|
40
|
+
@relays = Array(params['relay'])
|
|
41
|
+
raise InvalidConnectionStringError, 'at least one `relay` parameter is required' if @relays.empty?
|
|
42
|
+
|
|
43
|
+
@secret = params['secret']&.first&.downcase
|
|
44
|
+
Crypto::Keys.validate_hex32!(@secret, 'secret')
|
|
45
|
+
|
|
46
|
+
@lud16 = params['lud16']&.first
|
|
47
|
+
rescue URI::InvalidURIError => e
|
|
48
|
+
raise InvalidConnectionStringError, "malformed URI: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The client's own x-only pubkey, derived from `secret`.
|
|
52
|
+
def client_pubkey
|
|
53
|
+
@client_pubkey ||= Crypto::Keys.public_key_from_private(@secret)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_s
|
|
57
|
+
params = { 'relay' => @relays, 'secret' => [@secret] }
|
|
58
|
+
params['lud16'] = [@lud16] if @lud16
|
|
59
|
+
"nostr+walletconnect://#{@wallet_pubkey}?#{encode_query(params)}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def decode_query(query)
|
|
65
|
+
result = Hash.new { |h, k| h[k] = [] }
|
|
66
|
+
query.split('&').each do |pair|
|
|
67
|
+
next if pair.empty?
|
|
68
|
+
|
|
69
|
+
k, v = pair.split('=', 2)
|
|
70
|
+
result[URI.decode_www_form_component(k)] << URI.decode_www_form_component(v.to_s)
|
|
71
|
+
end
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def encode_query(params)
|
|
76
|
+
params.flat_map do |k, values|
|
|
77
|
+
Array(values).map { |v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }
|
|
78
|
+
end.join('&')
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbsecp256k1'
|
|
4
|
+
require 'ecdsa'
|
|
5
|
+
|
|
6
|
+
module NwcRuby
|
|
7
|
+
module Crypto
|
|
8
|
+
# ECDH for Nostr: compute the shared secret between our private key and
|
|
9
|
+
# their x-only public key.
|
|
10
|
+
#
|
|
11
|
+
# CRITICAL: Nostr ECDH returns the **X coordinate of the shared point only**
|
|
12
|
+
# (32 bytes). This differs from libsecp256k1's default `ecdh()` function,
|
|
13
|
+
# which returns SHA256(compressed_point). We have to do the multiplication
|
|
14
|
+
# ourselves using the `ecdsa` gem.
|
|
15
|
+
#
|
|
16
|
+
# This is used by both NIP-04 (as the AES key directly) and NIP-44 v2 (as
|
|
17
|
+
# the IKM for HKDF).
|
|
18
|
+
module ECDH
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
GROUP = ::ECDSA::Group::Secp256k1
|
|
22
|
+
|
|
23
|
+
# Returns the raw 32-byte X coordinate of the shared point.
|
|
24
|
+
#
|
|
25
|
+
# @param privkey_hex [String] our 32-byte private key (hex)
|
|
26
|
+
# @param xonly_pubkey_hex [String] their 32-byte x-only public key (hex)
|
|
27
|
+
# @return [String] 32 raw bytes (binary-encoded)
|
|
28
|
+
def shared_x(privkey_hex, xonly_pubkey_hex)
|
|
29
|
+
Keys.validate_hex32!(privkey_hex, 'private key')
|
|
30
|
+
Keys.validate_hex32!(xonly_pubkey_hex, 'public key')
|
|
31
|
+
|
|
32
|
+
# BIP-340 x-only pubkeys always correspond to the even-Y lifted point.
|
|
33
|
+
pubkey_point = lift_x(Keys.hex_to_bytes(xonly_pubkey_hex).unpack1('H*').to_i(16))
|
|
34
|
+
priv_int = privkey_hex.to_i(16)
|
|
35
|
+
|
|
36
|
+
shared_point = pubkey_point.multiply_by_scalar(priv_int)
|
|
37
|
+
[shared_point.x.to_s(16).rjust(64, '0')].pack('H*')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# BIP-340 "lift_x": given an x coordinate, return the point with even Y.
|
|
41
|
+
def lift_x(x)
|
|
42
|
+
raise EncryptionError, 'x out of range' if x.zero? || x >= GROUP.field.prime
|
|
43
|
+
|
|
44
|
+
p = GROUP.field.prime
|
|
45
|
+
c = (x.pow(3, p) + 7) % p
|
|
46
|
+
y = c.pow((p + 1) / 4, p)
|
|
47
|
+
raise EncryptionError, 'x is not on the curve' unless y.pow(2, p) == c
|
|
48
|
+
|
|
49
|
+
y = p - y if y.odd?
|
|
50
|
+
GROUP.new_point([x, y])
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbsecp256k1'
|
|
4
|
+
|
|
5
|
+
module NwcRuby
|
|
6
|
+
module Crypto
|
|
7
|
+
# Key helpers: hex <-> bytes, private key -> x-only pubkey, validation.
|
|
8
|
+
#
|
|
9
|
+
# Nostr uses BIP-340 Schnorr signatures over secp256k1. Public keys are
|
|
10
|
+
# "x-only" — just the 32-byte X coordinate. We use rbsecp256k1 (which wraps
|
|
11
|
+
# libsecp256k1) to derive them correctly.
|
|
12
|
+
module Keys
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Generate a new 32-byte private key as a lowercase hex string.
|
|
16
|
+
def generate_private_key
|
|
17
|
+
SecureRandom.bytes(32).unpack1('H*')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Derive the 32-byte x-only public key (hex) from a private key (hex).
|
|
21
|
+
def public_key_from_private(privkey_hex)
|
|
22
|
+
validate_hex32!(privkey_hex, 'private key')
|
|
23
|
+
ctx = ::Secp256k1::Context.create
|
|
24
|
+
kp = ctx.key_pair_from_private_key(hex_to_bytes(privkey_hex))
|
|
25
|
+
# rbsecp256k1 gives us a compressed public key (33 bytes, 02/03 prefix).
|
|
26
|
+
# X-only pubkey is just bytes 1..32.
|
|
27
|
+
compressed = kp.public_key.compressed
|
|
28
|
+
bytes_to_hex(compressed[1, 32])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def hex_to_bytes(hex)
|
|
32
|
+
[hex].pack('H*')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def bytes_to_hex(bytes)
|
|
36
|
+
bytes.unpack1('H*')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_hex32!(hex, label = 'value')
|
|
40
|
+
return if hex.is_a?(String) && hex.match?(/\A[0-9a-fA-F]{64}\z/)
|
|
41
|
+
|
|
42
|
+
raise InvalidConnectionStringError, "#{label} must be 64 hex characters (32 bytes)"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbsecp256k1'
|
|
4
|
+
|
|
5
|
+
module NwcRuby
|
|
6
|
+
module Crypto
|
|
7
|
+
# BIP-340 Schnorr signatures for Nostr events.
|
|
8
|
+
#
|
|
9
|
+
# Nostr event IDs are 32-byte SHA-256 hashes. The event signature is a
|
|
10
|
+
# 64-byte BIP-340 Schnorr signature over that hash, verifiable against the
|
|
11
|
+
# event's 32-byte x-only public key.
|
|
12
|
+
module Schnorr
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Sign a 32-byte digest with a private key. Returns 64-byte signature
|
|
16
|
+
# as a lowercase hex string (128 chars).
|
|
17
|
+
def sign(digest_bytes, privkey_hex)
|
|
18
|
+
raise ArgumentError, 'digest must be 32 bytes' unless digest_bytes.bytesize == 32
|
|
19
|
+
|
|
20
|
+
ctx = ::Secp256k1::Context.create
|
|
21
|
+
kp = ctx.key_pair_from_private_key(Keys.hex_to_bytes(privkey_hex))
|
|
22
|
+
sig = ctx.sign_schnorr(kp, digest_bytes)
|
|
23
|
+
Keys.bytes_to_hex(sig.serialized)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Verify a 64-byte Schnorr signature. Returns true / false.
|
|
27
|
+
def verify(digest_bytes, sig_hex, xonly_pubkey_hex)
|
|
28
|
+
return false unless sig_hex.is_a?(String) && sig_hex.length == 128
|
|
29
|
+
|
|
30
|
+
xonly_pub = ::Secp256k1::XOnlyPublicKey.from_data(Keys.hex_to_bytes(xonly_pubkey_hex))
|
|
31
|
+
sig_obj = ::Secp256k1::SchnorrSignature.from_data(Keys.hex_to_bytes(sig_hex))
|
|
32
|
+
sig_obj.verify(digest_bytes, xonly_pub)
|
|
33
|
+
rescue ::Secp256k1::Error, ::Secp256k1::DeserializationError, ArgumentError
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NwcRuby
|
|
4
|
+
# Base class for all gem errors. Rescue this to catch anything the gem raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# The connection string is missing or malformed.
|
|
8
|
+
class InvalidConnectionStringError < Error; end
|
|
9
|
+
|
|
10
|
+
# Encryption / decryption failed (bad MAC, bad padding, unknown version byte).
|
|
11
|
+
class EncryptionError < Error; end
|
|
12
|
+
|
|
13
|
+
# Signature verification failed on an inbound event.
|
|
14
|
+
class InvalidSignatureError < Error; end
|
|
15
|
+
|
|
16
|
+
# The relay could not be reached, or the connection died and did not recover
|
|
17
|
+
# within the configured timeout.
|
|
18
|
+
class TransportError < Error; end
|
|
19
|
+
|
|
20
|
+
# The wallet service returned an error envelope. `#code` is the NIP-47 error
|
|
21
|
+
# code (one of RATE_LIMITED, NOT_IMPLEMENTED, INSUFFICIENT_BALANCE,
|
|
22
|
+
# QUOTA_EXCEEDED, RESTRICTED, UNAUTHORIZED, INTERNAL, UNSUPPORTED_ENCRYPTION,
|
|
23
|
+
# PAYMENT_FAILED, NOT_FOUND, or OTHER).
|
|
24
|
+
class WalletServiceError < Error
|
|
25
|
+
attr_reader :code
|
|
26
|
+
|
|
27
|
+
def initialize(code, message)
|
|
28
|
+
@code = code
|
|
29
|
+
super("#{code}: #{message}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A request was sent but no response arrived within the timeout window.
|
|
34
|
+
class TimeoutError < Error; end
|
|
35
|
+
|
|
36
|
+
# The wallet service does not support the method we tried to call. Check
|
|
37
|
+
# `Client#capabilities` first, or use a read+write NWC string.
|
|
38
|
+
class UnsupportedMethodError < Error; end
|
|
39
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NwcRuby
|
|
4
|
+
# A Nostr event (NIP-01).
|
|
5
|
+
#
|
|
6
|
+
# The canonical serialization for ID computation is a JSON array with no
|
|
7
|
+
# whitespace between elements:
|
|
8
|
+
# [0, pubkey, created_at, kind, tags, content]
|
|
9
|
+
# SHA-256 of that JSON is the event id; the event id is signed with BIP-340
|
|
10
|
+
# Schnorr against the author's private key.
|
|
11
|
+
class Event
|
|
12
|
+
attr_accessor :id, :pubkey, :created_at, :kind, :tags, :content, :sig
|
|
13
|
+
|
|
14
|
+
def initialize(pubkey:, kind:, content:, tags: [], created_at: Time.now.to_i)
|
|
15
|
+
@pubkey = pubkey
|
|
16
|
+
@kind = kind
|
|
17
|
+
@content = content
|
|
18
|
+
@tags = tags
|
|
19
|
+
@created_at = created_at
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Build from a hash as received from the relay.
|
|
23
|
+
def self.from_hash(h)
|
|
24
|
+
e = allocate
|
|
25
|
+
e.id = h['id']
|
|
26
|
+
e.pubkey = h['pubkey']
|
|
27
|
+
e.created_at = h['created_at']
|
|
28
|
+
e.kind = h['kind']
|
|
29
|
+
e.tags = h['tags'] || []
|
|
30
|
+
e.content = h['content'] || ''
|
|
31
|
+
e.sig = h['sig']
|
|
32
|
+
e
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# JSON used for ID computation. MUST NOT contain whitespace between tokens
|
|
36
|
+
# and MUST use the array form below.
|
|
37
|
+
def serialize_for_id
|
|
38
|
+
JSON.generate([0, @pubkey, @created_at, @kind, @tags, @content])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def compute_id!
|
|
42
|
+
@id = OpenSSL::Digest::SHA256.hexdigest(serialize_for_id)
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def sign!(privkey_hex)
|
|
47
|
+
compute_id!
|
|
48
|
+
digest_bytes = Crypto::Keys.hex_to_bytes(@id)
|
|
49
|
+
@sig = Crypto::Schnorr.sign(digest_bytes, privkey_hex)
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def valid_signature?
|
|
54
|
+
return false unless @id && @sig && @pubkey
|
|
55
|
+
|
|
56
|
+
digest_bytes = Crypto::Keys.hex_to_bytes(@id)
|
|
57
|
+
Crypto::Schnorr.verify(digest_bytes, @sig, @pubkey)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_h
|
|
61
|
+
{
|
|
62
|
+
'id' => @id,
|
|
63
|
+
'pubkey' => @pubkey,
|
|
64
|
+
'created_at' => @created_at,
|
|
65
|
+
'kind' => @kind,
|
|
66
|
+
'tags' => @tags,
|
|
67
|
+
'content' => @content,
|
|
68
|
+
'sig' => @sig
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|