nwc-ruby 0.3.1 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d4c011d51eac1622cceede610301765731ca586ffddea5a3e99cbeba7fdd28e
4
- data.tar.gz: 68b349cd70ae9d423111a6d4b04c3c6bf088e100a6cad066c93504b0cd078ca0
3
+ metadata.gz: eb3477afba784c35d4e2db68ea408f30efec1330516c24f368471b123dcc7ddc
4
+ data.tar.gz: aabf55cb7cbeb1263945e457aab8f2908f39e560f0297d7406adc31ba7e56a73
5
5
  SHA512:
6
- metadata.gz: da57ea11db970c08836488cd99a617b85ddff08242facb3c5f2d5984957507ab87597f5b6d9844134550f46b209c97e13163345b2c419b2968392c486848a637
7
- data.tar.gz: fb90130f2bdd51bb5e650e2b772979ce5573dcd19363d5a8bcebb5f5df17baab29c51401faea95fb9eca64ec06e5335078d95a373556664b715813b61e77730c
6
+ metadata.gz: 9c52a015142aec41f912b9f3881f56e5ce8289be0106f000be2786677320426e250ac73b70a9905c691e082ac293286caf169aac453af92886380053b7e34461
7
+ data.tar.gz: 73035a32f7fe34602f527f44941703ebecf7c505319f5e4899e8a6168350b2ae5d108f3be1e8c661d2854b05c087e752f44d6960a9f401ac880be0776d6a6166
data/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] — 2026-10-20
11
+
12
+ ### Changed
13
+
14
+ - **Each connection now derives its encryption key once, not on every
15
+ message.** NIP-04 and NIP-44 v2 both start from an ECDH that this gem runs
16
+ as pure-Ruby scalar multiplication: about 33 ms on an Apple-silicon laptop,
17
+ more on a typical server vCPU. Every request ran it twice (encrypting the
18
+ request, decrypting the response) and every notification once. The result
19
+ depends only on the client secret and the wallet pubkey, both fixed when the
20
+ connection string is parsed, so `ConnectionString#conversation` now computes
21
+ it on first use and keeps it. A `Client` reused across calls pays for ECDH
22
+ once in its lifetime. Nonces and IVs are still drawn fresh for every
23
+ message, and the wire format is unchanged.
24
+ - A response that fails NIP-44 decryption and falls back to NIP-04 reuses the
25
+ same ECDH instead of running it a second time.
26
+
27
+ ### Added
28
+
29
+ - `NwcRuby::Crypto::Conversation`: the NIP-04 and NIP-44 v2 keys for one
30
+ (private key, public key) pair, derived lazily and cached. A bad key raises
31
+ at first use, as before, and a failure is not cached.
32
+ - `NIP44::Cipher.encrypt_with` / `decrypt_with` and
33
+ `NIP04::Cipher.encrypt_with` / `decrypt_with`, which take a `Conversation`
34
+ in place of the two hex keys. The existing `encrypt` / `decrypt` signatures
35
+ are unchanged.
36
+ - An optional `conversation:` keyword on `NIP47::Request.build`,
37
+ `NIP47::Response.parse` and `NIP47::Notification.parse`.
38
+ - Specs against paulmillr's `get_conversation_key` and `encrypt_decrypt`
39
+ NIP-44 vectors.
40
+
10
41
  ## [0.3.1] — 2026-10-05
11
42
 
12
43
  ### Fixed
data/README.md CHANGED
@@ -528,6 +528,11 @@ the request is written to the relay are retried across every `relay` in the
528
528
  connection string, with 0.25 s / 1 s backoff, inside `request_timeout`.
529
529
  Nothing is ever retried after the request is written.
530
530
 
531
+ Build one `Client` per connection string and reuse it. It caches the wallet's
532
+ info event and the connection's encryption key, so only its first call pays
533
+ for the ECDH (tens of milliseconds in pure Ruby); a `Client` built per request
534
+ pays it on every call.
535
+
531
536
  Introspection:
532
537
 
533
538
  | Method | Returns |
@@ -207,7 +207,8 @@ module NwcRuby
207
207
  last_seen_at = event.created_at if event.created_at && event.created_at > last_seen_at
208
208
 
209
209
  begin
210
- notification = NIP47::Notification.parse(event, @connection_string.secret, @connection_string.wallet_pubkey)
210
+ notification = NIP47::Notification.parse(event, @connection_string.secret, @connection_string.wallet_pubkey,
211
+ conversation: @connection_string.conversation)
211
212
  rescue EncryptionError => e
212
213
  @logger.warn("[nwc] could not decrypt notification: #{e.message}")
213
214
  next
@@ -282,7 +283,8 @@ module NwcRuby
282
283
  params: params,
283
284
  client_privkey: @connection_string.secret,
284
285
  wallet_pubkey: @connection_string.wallet_pubkey,
285
- encryption: @info.preferred_encryption
286
+ encryption: @info.preferred_encryption,
287
+ conversation: @connection_string.conversation
286
288
  )
287
289
 
288
290
  response = with_relays(deadline) { |url| send_request(url, deadline, method, request_event) }
@@ -328,7 +330,8 @@ module NwcRuby
328
330
  event = relay_event(parsed, sub_id)
329
331
  next unless event && event.kind == NIP47::Methods::KIND_RESPONSE && response_to?(event, request_id)
330
332
 
331
- return NIP47::Response.parse(event, @connection_string.secret, @connection_string.wallet_pubkey)
333
+ return NIP47::Response.parse(event, @connection_string.secret, @connection_string.wallet_pubkey,
334
+ conversation: @connection_string.conversation)
332
335
  end
333
336
  raise TransportError, 'relay closed the connection before the wallet responded'
334
337
  end
@@ -53,6 +53,13 @@ module NwcRuby
53
53
  @client_pubkey ||= Crypto::Keys.public_key_from_private(@secret)
54
54
  end
55
55
 
56
+ # Encryption keys for this client/wallet pair. Both inputs are fixed at
57
+ # parse time, so the ECDH behind them runs once per connection string
58
+ # rather than on every request, response and notification.
59
+ def conversation
60
+ @conversation ||= Crypto::Conversation.new(@secret, @wallet_pubkey)
61
+ end
62
+
56
63
  def to_s
57
64
  params = { 'relay' => @relays, 'secret' => [@secret] }
58
65
  params['lud16'] = [@lud16] if @lud16
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NwcRuby
4
+ module Crypto
5
+ # The symmetric keys for one (our private key, their public key) pair.
6
+ #
7
+ # NIP-04 and NIP-44 v2 both start from the same ECDH, which is the slow
8
+ # step here (pure-Ruby scalar multiplication, tens of milliseconds) and
9
+ # depends only on the two keys. This object runs it once, on first use,
10
+ # and keeps the result. Per-message randomness is unaffected: NIP-44 still
11
+ # draws a fresh nonce and NIP-04 a fresh IV on every encrypt.
12
+ #
13
+ # Nothing is computed in `initialize`, so a bad key raises at first use,
14
+ # exactly where the uncached path raises it, and a failure is not cached.
15
+ class Conversation
16
+ def initialize(privkey_hex, pubkey_hex)
17
+ @privkey_hex = privkey_hex
18
+ @pubkey_hex = pubkey_hex
19
+ end
20
+
21
+ # NIP-04's AES-256 key: the raw shared X coordinate.
22
+ def nip04_key
23
+ @nip04_key ||= ECDH.shared_x(@privkey_hex, @pubkey_hex)
24
+ end
25
+
26
+ # NIP-44 v2's conversation key: HKDF-extract over the same shared X.
27
+ def nip44_key
28
+ @nip44_key ||= NIP44::Cipher.hkdf_extract(NIP44::Cipher::SALT.b, nip04_key)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -14,11 +14,17 @@ module NwcRuby
14
14
  module_function
15
15
 
16
16
  def encrypt(plaintext, privkey_hex, pubkey_hex)
17
- key = Crypto::ECDH.shared_x(privkey_hex, pubkey_hex)
18
- iv = SecureRandom.bytes(16)
17
+ encrypt_with(plaintext, Crypto::Conversation.new(privkey_hex, pubkey_hex))
18
+ end
19
+
20
+ # `encrypt`, reusing the conversation's cached key instead of redoing ECDH.
21
+ #
22
+ # @param conversation [Crypto::Conversation]
23
+ def encrypt_with(plaintext, conversation)
24
+ iv = SecureRandom.bytes(16)
19
25
 
20
26
  cipher = OpenSSL::Cipher.new('aes-256-cbc').encrypt
21
- cipher.key = key
27
+ cipher.key = conversation.nip04_key
22
28
  cipher.iv = iv
23
29
  ct = cipher.update(plaintext.encode('UTF-8')) + cipher.final
24
30
 
@@ -26,6 +32,14 @@ module NwcRuby
26
32
  end
27
33
 
28
34
  def decrypt(payload, privkey_hex, pubkey_hex)
35
+ decrypt_with(payload, Crypto::Conversation.new(privkey_hex, pubkey_hex))
36
+ end
37
+
38
+ # `decrypt`, reusing the conversation's cached key instead of redoing
39
+ # ECDH. The payload is checked before the key is touched.
40
+ #
41
+ # @param conversation [Crypto::Conversation]
42
+ def decrypt_with(payload, conversation)
29
43
  raise EncryptionError, 'malformed NIP-04 payload' unless payload.include?('?iv=')
30
44
 
31
45
  ct_b64, iv_b64 = payload.split('?iv=', 2)
@@ -33,10 +47,8 @@ module NwcRuby
33
47
  iv = Base64.strict_decode64(iv_b64)
34
48
  raise EncryptionError, 'invalid IV length' unless iv.bytesize == 16
35
49
 
36
- key = Crypto::ECDH.shared_x(privkey_hex, pubkey_hex)
37
-
38
50
  cipher = OpenSSL::Cipher.new('aes-256-cbc').decrypt
39
- cipher.key = key
51
+ cipher.key = conversation.nip04_key
40
52
  cipher.iv = iv
41
53
  (cipher.update(ct) + cipher.final).force_encoding('UTF-8')
42
54
  rescue OpenSSL::Cipher::CipherError, ArgumentError => e
@@ -37,12 +37,19 @@ module NwcRuby
37
37
  # @param nonce [String, nil] optional 32-byte nonce (for tests); random if nil
38
38
  # @return [String] base64 payload
39
39
  def encrypt(plaintext, privkey_hex, pubkey_hex, nonce: nil)
40
+ encrypt_with(plaintext, Crypto::Conversation.new(privkey_hex, pubkey_hex), nonce: nonce)
41
+ end
42
+
43
+ # `encrypt`, reusing the conversation's cached key instead of redoing ECDH.
44
+ #
45
+ # @param conversation [Crypto::Conversation]
46
+ def encrypt_with(plaintext, conversation, nonce: nil)
40
47
  pt_bytes = plaintext.to_s.dup.force_encoding('UTF-8').b
41
48
  if pt_bytes.bytesize < MIN_PLAINTEXT || pt_bytes.bytesize > MAX_PLAINTEXT
42
49
  raise EncryptionError, 'plaintext length must be 1..65535 bytes'
43
50
  end
44
51
 
45
- conversation_key = derive_conversation_key(privkey_hex, pubkey_hex)
52
+ conversation_key = conversation.nip44_key
46
53
  nonce ||= SecureRandom.bytes(32)
47
54
  raise EncryptionError, 'nonce must be 32 bytes' unless nonce.bytesize == 32
48
55
 
@@ -56,7 +63,16 @@ module NwcRuby
56
63
 
57
64
  # @param payload [String] base64 NIP-44 payload
58
65
  # @return [String] UTF-8 plaintext
59
- def decrypt(payload, privkey_hex, pubkey_hex) # rubocop:disable Metrics/AbcSize
66
+ def decrypt(payload, privkey_hex, pubkey_hex)
67
+ decrypt_with(payload, Crypto::Conversation.new(privkey_hex, pubkey_hex))
68
+ end
69
+
70
+ # `decrypt`, reusing the conversation's cached key instead of redoing
71
+ # ECDH. The payload is fully checked before the key is touched, so a
72
+ # malformed one is rejected without paying for ECDH.
73
+ #
74
+ # @param conversation [Crypto::Conversation]
75
+ def decrypt_with(payload, conversation) # rubocop:disable Metrics/AbcSize
60
76
  raise EncryptionError, 'payload is nil or empty' if payload.nil? || payload.empty?
61
77
  raise EncryptionError, "payload starts with '#' (not encrypted)" if payload.start_with?('#')
62
78
 
@@ -77,8 +93,7 @@ module NwcRuby
77
93
  mac = raw.byteslice(raw.bytesize - 32, 32)
78
94
  ciphertext = raw.byteslice(33, raw.bytesize - 33 - 32)
79
95
 
80
- conversation_key = derive_conversation_key(privkey_hex, pubkey_hex)
81
- chacha_key, chacha_nonce, hmac_key = derive_message_keys(conversation_key, nonce)
96
+ chacha_key, chacha_nonce, hmac_key = derive_message_keys(conversation.nip44_key, nonce)
82
97
 
83
98
  expected_mac = OpenSSL::HMAC.digest('SHA256', hmac_key, nonce + ciphertext)
84
99
  raise EncryptionError, 'NIP-44 MAC verification failed' unless secure_compare(mac, expected_mac)
@@ -90,8 +105,7 @@ module NwcRuby
90
105
  # --- Internals ------------------------------------------------------
91
106
 
92
107
  def derive_conversation_key(privkey_hex, pubkey_hex)
93
- shared = Crypto::ECDH.shared_x(privkey_hex, pubkey_hex)
94
- hkdf_extract(SALT.b, shared)
108
+ Crypto::Conversation.new(privkey_hex, pubkey_hex).nip44_key
95
109
  end
96
110
 
97
111
  def derive_message_keys(conversation_key, nonce)
@@ -32,12 +32,15 @@ module NwcRuby
32
32
  @type == 'payment_sent'
33
33
  end
34
34
 
35
- def self.parse(event, client_privkey, wallet_pubkey)
35
+ # @param conversation [Crypto::Conversation, nil] cached keys for this
36
+ # client/wallet pair; derived from the two keys when nil
37
+ def self.parse(event, client_privkey, wallet_pubkey, conversation: nil)
38
+ conversation ||= Crypto::Conversation.new(client_privkey, wallet_pubkey)
36
39
  plaintext = case event.kind
37
40
  when Methods::KIND_NOTIFICATION_NIP44
38
- NIP44::Cipher.decrypt(event.content, client_privkey, wallet_pubkey)
41
+ NIP44::Cipher.decrypt_with(event.content, conversation)
39
42
  when Methods::KIND_NOTIFICATION_NIP04
40
- NIP04::Cipher.decrypt(event.content, client_privkey, wallet_pubkey)
43
+ NIP04::Cipher.decrypt_with(event.content, conversation)
41
44
  else
42
45
  raise ArgumentError, "not a notification kind: #{event.kind}"
43
46
  end
@@ -13,12 +13,16 @@ module NwcRuby
13
13
  # @param wallet_pubkey [String] hex
14
14
  # @param encryption [Symbol] :nip44_v2 or :nip04
15
15
  # @param expiration [Integer, nil] optional unix timestamp
16
+ # @param conversation [Crypto::Conversation, nil] cached keys for this
17
+ # client/wallet pair; derived from the two keys above when nil
16
18
  # @return [Event]
17
- def build(method:, params:, client_privkey:, wallet_pubkey:, encryption: :nip44_v2, expiration: nil)
19
+ def build(method:, params:, client_privkey:, wallet_pubkey:, encryption: :nip44_v2, expiration: nil,
20
+ conversation: nil)
21
+ conversation ||= Crypto::Conversation.new(client_privkey, wallet_pubkey)
18
22
  payload = JSON.generate({ 'method' => method, 'params' => params })
19
23
  ciphertext = case encryption
20
- when :nip44_v2 then NIP44::Cipher.encrypt(payload, client_privkey, wallet_pubkey)
21
- when :nip04 then NIP04::Cipher.encrypt(payload, client_privkey, wallet_pubkey)
24
+ when :nip44_v2 then NIP44::Cipher.encrypt_with(payload, conversation)
25
+ when :nip04 then NIP04::Cipher.encrypt_with(payload, conversation)
22
26
  else raise ArgumentError, "unknown encryption: #{encryption}"
23
27
  end
24
28
 
@@ -29,13 +29,16 @@ module NwcRuby
29
29
  # @param event [Event] the kind 23195 event (already signature-verified)
30
30
  # @param client_privkey [String] hex
31
31
  # @param wallet_pubkey [String] hex
32
+ # @param conversation [Crypto::Conversation, nil] cached keys for this
33
+ # client/wallet pair; derived from the two keys above when nil
32
34
  # @return [Response]
33
- def self.parse(event, client_privkey, wallet_pubkey)
35
+ def self.parse(event, client_privkey, wallet_pubkey, conversation: nil)
34
36
  # Tags: ["p", client_pubkey], ["e", request_event_id]
35
37
  e_tag = event.tags.find { |t| t[0] == 'e' }
36
38
  request_id = e_tag && e_tag[1]
37
39
 
38
- plaintext = decrypt(event.content, client_privkey, wallet_pubkey)
40
+ conversation ||= Crypto::Conversation.new(client_privkey, wallet_pubkey)
41
+ plaintext = decrypt_with(event.content, conversation)
39
42
  data = JSON.parse(plaintext)
40
43
 
41
44
  new(
@@ -47,11 +50,17 @@ module NwcRuby
47
50
  )
48
51
  end
49
52
 
50
- # Try NIP-44 v2 first; fall back to NIP-04 if the version byte is wrong.
51
53
  def self.decrypt(content, client_privkey, wallet_pubkey)
52
- NIP44::Cipher.decrypt(content, client_privkey, wallet_pubkey)
54
+ decrypt_with(content, Crypto::Conversation.new(client_privkey, wallet_pubkey))
55
+ end
56
+
57
+ # Try NIP-44 v2 first; fall back to NIP-04 if the version byte is wrong.
58
+ # Both attempts share the conversation, so the fallback reuses any ECDH
59
+ # the NIP-44 attempt already ran.
60
+ def self.decrypt_with(content, conversation)
61
+ NIP44::Cipher.decrypt_with(content, conversation)
53
62
  rescue EncryptionError
54
- NIP04::Cipher.decrypt(content, client_privkey, wallet_pubkey)
63
+ NIP04::Cipher.decrypt_with(content, conversation)
55
64
  end
56
65
  end
57
66
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NwcRuby
4
- VERSION = '0.3.1'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/nwc_ruby.rb CHANGED
@@ -12,6 +12,7 @@ require_relative 'nwc_ruby/errors'
12
12
  require_relative 'nwc_ruby/crypto/keys'
13
13
  require_relative 'nwc_ruby/crypto/schnorr'
14
14
  require_relative 'nwc_ruby/crypto/ecdh'
15
+ require_relative 'nwc_ruby/crypto/conversation'
15
16
  require_relative 'nwc_ruby/nip04/cipher'
16
17
  require_relative 'nwc_ruby/nip44/cipher'
17
18
  require_relative 'nwc_ruby/event'
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.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - MegalithicBTC
@@ -183,6 +183,7 @@ files:
183
183
  - lib/nwc_ruby.rb
184
184
  - lib/nwc_ruby/client.rb
185
185
  - lib/nwc_ruby/connection_string.rb
186
+ - lib/nwc_ruby/crypto/conversation.rb
186
187
  - lib/nwc_ruby/crypto/ecdh.rb
187
188
  - lib/nwc_ruby/crypto/keys.rb
188
189
  - lib/nwc_ruby/crypto/schnorr.rb
@@ -222,7 +223,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
222
223
  - !ruby/object:Gem::Version
223
224
  version: '0'
224
225
  requirements: []
225
- rubygems_version: 3.6.8
226
+ rubygems_version: 3.6.9
226
227
  specification_version: 4
227
228
  summary: Ruby client for Nostr Wallet Connect (NIP-47) with safe long-running relay
228
229
  connections.