nwc-ruby 0.3.1 → 0.5.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: e2d94a88c6465f4422539d861f346b8d7ab7726d2805be0c4b9d0705b6dd9399
4
+ data.tar.gz: ae16489410ce6cb3663a93c3aa5e20b84ff9c920294cebb5ab690a00f12da1f0
5
5
  SHA512:
6
- metadata.gz: da57ea11db970c08836488cd99a617b85ddff08242facb3c5f2d5984957507ab87597f5b6d9844134550f46b209c97e13163345b2c419b2968392c486848a637
7
- data.tar.gz: fb90130f2bdd51bb5e650e2b772979ce5573dcd19363d5a8bcebb5f5df17baab29c51401faea95fb9eca64ec06e5335078d95a373556664b715813b61e77730c
6
+ metadata.gz: d91f00c5dc93b17b8c6eb9cbcdc0e3a31175fcd1b0ddcb843bd3fd3a58948cdbe1844a5f0dcf3e363915eb6339071b9f3691c564f66362eb96f3431dea1b3db3
7
+ data.tar.gz: 3169a1dd02f32a5bbefd6c03b037e35acbd3773922d88c145fad408b8fc18d4bd5a1b4c26e5aed34909a036750c81f64db4264e38cfca5367516796ee0b23a65
data/CHANGELOG.md CHANGED
@@ -7,6 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] — 2026-11-08
11
+
12
+ ### Changed
13
+
14
+ - **ECDH is native, about 58x faster.** NIP-04 and NIP-44 v2 derive their
15
+ keys from an ECDH that this gem computed as pure-Ruby scalar multiplication
16
+ with the `ecdsa` gem. It now runs in OpenSSL through Ruby's built-in
17
+ `openssl` library: 34.3 ms → 0.60 ms per ECDH on an Apple-silicon laptop
18
+ (Docker, aarch64 Linux, Ruby 3.4.9, OpenSSL 3.5.6). A `Client` built per
19
+ request gains the most; a reused `Client` has run ECDH once per connection
20
+ since 0.4.0. The output is byte-for-byte unchanged: checked against the
21
+ 0.4.0 code on 6,171 key pairs (including private keys and shared X values
22
+ with leading zero bytes) and against paulmillr's NIP-44 vectors. OpenSSL
23
+ also multiplies secret scalars with a constant-time ladder, which the
24
+ `ecdsa` gem did not.
25
+
26
+ ### Removed
27
+
28
+ - The `ecdsa` runtime dependency, along with `NwcRuby::Crypto::ECDH.lift_x`
29
+ and `ECDH::GROUP`. OpenSSL now decodes the peer's even-Y point from
30
+ `02 || x`.
31
+
32
+ ### Added
33
+
34
+ - `NwcRuby::CryptoUnavailableError`, raised when this Ruby's OpenSSL cannot
35
+ do secp256k1 ECDH, e.g. in FIPS mode. It is deliberately not an
36
+ `EncryptionError`, which the notification listener treats as one bad
37
+ message and skips. `NwcRuby::Crypto::ECDH.available?` checks ahead of time.
38
+ - Specs: paulmillr's full `nip44.vectors.json`, pinned to the SHA-256 that
39
+ NIP-44 publishes (all 35 valid and 8 invalid `get_conversation_key`
40
+ vectors, all 10 `encrypt_decrypt` vectors), and ECDH specs checked against
41
+ libsecp256k1's point multiplication.
42
+
43
+ ### Fixed
44
+
45
+ - `ECDH.shared_x` rejects an out-of-range private key with `EncryptionError`
46
+ ("private key out of range"). Before, 0 or n raised a bare `ArgumentError`
47
+ and a key above n was silently reduced mod n. Off-curve and out-of-range
48
+ public keys raise the same `EncryptionError` messages as before.
49
+
50
+ ## [0.4.0] — 2026-10-20
51
+
52
+ ### Changed
53
+
54
+ - **Each connection now derives its encryption key once, not on every
55
+ message.** NIP-04 and NIP-44 v2 both start from an ECDH that this gem runs
56
+ as pure-Ruby scalar multiplication: about 33 ms on an Apple-silicon laptop,
57
+ more on a typical server vCPU. Every request ran it twice (encrypting the
58
+ request, decrypting the response) and every notification once. The result
59
+ depends only on the client secret and the wallet pubkey, both fixed when the
60
+ connection string is parsed, so `ConnectionString#conversation` now computes
61
+ it on first use and keeps it. A `Client` reused across calls pays for ECDH
62
+ once in its lifetime. Nonces and IVs are still drawn fresh for every
63
+ message, and the wire format is unchanged.
64
+ - A response that fails NIP-44 decryption and falls back to NIP-04 reuses the
65
+ same ECDH instead of running it a second time.
66
+
67
+ ### Added
68
+
69
+ - `NwcRuby::Crypto::Conversation`: the NIP-04 and NIP-44 v2 keys for one
70
+ (private key, public key) pair, derived lazily and cached. A bad key raises
71
+ at first use, as before, and a failure is not cached.
72
+ - `NIP44::Cipher.encrypt_with` / `decrypt_with` and
73
+ `NIP04::Cipher.encrypt_with` / `decrypt_with`, which take a `Conversation`
74
+ in place of the two hex keys. The existing `encrypt` / `decrypt` signatures
75
+ are unchanged.
76
+ - An optional `conversation:` keyword on `NIP47::Request.build`,
77
+ `NIP47::Response.parse` and `NIP47::Notification.parse`.
78
+ - Specs against paulmillr's `get_conversation_key` and `encrypt_decrypt`
79
+ NIP-44 vectors.
80
+
10
81
  ## [0.3.1] — 2026-10-05
11
82
 
12
83
  ### Fixed
data/README.md CHANGED
@@ -166,6 +166,15 @@ If you see `LoadError: cannot load such file -- secp256k1` at runtime, the
166
166
  native extension wasn't compiled. Install the build dependencies above and
167
167
  run `gem pristine rbsecp256k1` (or re-run `bundle install`) to rebuild it.
168
168
 
169
+ NIP-04 and NIP-44 encryption run their ECDH through Ruby's built-in `openssl`,
170
+ which needs an OpenSSL that allows secp256k1. Standard builds do. OpenSSL in
171
+ FIPS mode does not, and then every encrypt and decrypt raises
172
+ `NwcRuby::CryptoUnavailableError`. To check at boot:
173
+
174
+ ```ruby
175
+ NwcRuby::Crypto::ECDH.available? # => true
176
+ ```
177
+
169
178
  ---
170
179
 
171
180
  ## Usage
@@ -528,6 +537,11 @@ the request is written to the relay are retried across every `relay` in the
528
537
  connection string, with 0.25 s / 1 s backoff, inside `request_timeout`.
529
538
  Nothing is ever retried after the request is written.
530
539
 
540
+ Build one `Client` per connection string and reuse it. It caches the wallet's
541
+ info event (a relay round trip) and the connection's encryption key (an ECDH,
542
+ under a millisecond), so only its first call pays for them; a `Client` built
543
+ per request pays for both on every call.
544
+
531
545
  Introspection:
532
546
 
533
547
  | Method | Returns |
@@ -577,6 +591,9 @@ the original is kept as `#cause`.
577
591
 
578
592
  - `InvalidConnectionStringError` — the URI couldn't be parsed.
579
593
  - `EncryptionError` — bad MAC / bad padding / unknown version byte / bad key.
594
+ - `CryptoUnavailableError` — this Ruby's OpenSSL can't do secp256k1 ECDH
595
+ (FIPS mode, or a build without the curve), so nothing can be encrypted or
596
+ decrypted. Retrying won't help.
580
597
  - `InvalidSignatureError` — an event's signature did not verify.
581
598
  - `TransportError` — the connection failed **after** the request was written
582
599
  to the relay. The wallet may have acted on it: reconcile with
@@ -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, the costliest step
8
+ # here (a scalar multiplication) and one that depends only on the two
9
+ # keys. This object runs it once, on first use, and keeps the result.
10
+ # Per-message randomness is unaffected: NIP-44 still draws a fresh nonce
11
+ # 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
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rbsecp256k1'
4
- require 'ecdsa'
3
+ require 'openssl'
5
4
 
6
5
  module NwcRuby
7
6
  module Crypto
@@ -9,45 +8,119 @@ module NwcRuby
9
8
  # their x-only public key.
10
9
  #
11
10
  # 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.
11
+ # (32 bytes). libsecp256k1's `ecdh()` returns SHA256(compressed_point)
12
+ # instead, and rbsecp256k1 hard-codes that hash, so it can't be used here.
13
+ # OpenSSL's ECDH returns the raw X coordinate, left-padded to 32 bytes.
15
14
  #
16
15
  # This is used by both NIP-04 (as the AES key directly) and NIP-44 v2 (as
17
16
  # the IKM for HKDF).
18
17
  module ECDH
19
18
  module_function
20
19
 
21
- GROUP = ::ECDSA::Group::Secp256k1
20
+ # secp256k1 field prime and group order (SEC 2, section 2.4.1).
21
+ P = 0xFFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFE_FFFFFC2F
22
+ N = 0xFFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFE_BAAEDCE6_AF48A03B_BFD25E8C_D0364141
23
+
24
+ # Numeric OIDs, so nothing depends on OpenSSL's curve-name table.
25
+ SECP256K1_OID = '1.3.132.0.10'
26
+ EC_PUBLIC_KEY_OID = '1.2.840.10045.2.1'
27
+
28
+ # X coordinate of the generator G, for the self-test in `available?`.
29
+ GX_HEX = '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'
22
30
 
23
31
  # Returns the raw 32-byte X coordinate of the shared point.
24
32
  #
25
33
  # @param privkey_hex [String] our 32-byte private key (hex)
26
34
  # @param xonly_pubkey_hex [String] their 32-byte x-only public key (hex)
27
35
  # @return [String] 32 raw bytes (binary-encoded)
36
+ # @raise [EncryptionError] if either key is out of range or the public
37
+ # key is not on the curve
38
+ # @raise [CryptoUnavailableError] if this OpenSSL lacks secp256k1
28
39
  def shared_x(privkey_hex, xonly_pubkey_hex)
29
40
  Keys.validate_hex32!(privkey_hex, 'private key')
30
41
  Keys.validate_hex32!(xonly_pubkey_hex, 'public key')
31
42
 
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)
43
+ d = privkey_hex.to_i(16)
44
+ raise EncryptionError, 'private key out of range' if d.zero? || d >= N
35
45
 
36
- shared_point = pubkey_point.multiply_by_scalar(priv_int)
37
- [shared_point.x.to_s(16).rjust(64, '0')].pack('H*')
46
+ x = xonly_pubkey_hex.to_i(16)
47
+ raise EncryptionError, 'x out of range' if x.zero? || x >= P
48
+
49
+ ensure_available!
50
+ derive(Keys.hex_to_bytes(privkey_hex), Keys.hex_to_bytes(xonly_pubkey_hex))
38
51
  end
39
52
 
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
53
+ # Whether this Ruby's OpenSSL can do secp256k1 ECDH. Some builds leave
54
+ # the curve out, and FIPS mode refuses it. Under RHEL 9's FIPS provider
55
+ # the curve's group still loads and only the key operations fail, so
56
+ # this runs a real ECDH with a known answer (1 * G has X coordinate GX)
57
+ # instead of looking the curve up. Checked once per process.
58
+ def available?
59
+ return @available unless @available.nil?
43
60
 
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
61
+ gx = Keys.hex_to_bytes(GX_HEX)
62
+ @available = begin
63
+ derive(Keys.hex_to_bytes("#{'0' * 63}1"), gx) == gx
64
+ rescue EncryptionError
65
+ false
66
+ end
67
+ end
68
+
69
+ def ensure_available!
70
+ return if available?
71
+
72
+ reason = if ::OpenSSL.fips_mode
73
+ 'OpenSSL is in FIPS mode, which does not allow it'
74
+ else
75
+ 'this OpenSSL build lacks it'
76
+ end
77
+ raise CryptoUnavailableError,
78
+ "secp256k1 ECDH is unavailable in #{::OpenSSL::OPENSSL_LIBRARY_VERSION} (#{reason}); " \
79
+ 'NIP-04 and NIP-44 encryption both need it'
80
+ end
81
+
82
+ # ECDH on validated, in-range inputs. The peer is the even-Y point with
83
+ # X coordinate `x_bytes` (BIP-340 lift_x), encoded as "02" || x.
84
+ def derive(d_bytes, x_bytes)
85
+ theirs = begin
86
+ ::OpenSSL::PKey.read(public_key_der(x_bytes))
87
+ rescue ::OpenSSL::OpenSSLError
88
+ raise EncryptionError, 'x is not on the curve'
89
+ end
90
+
91
+ begin
92
+ secret = ::OpenSSL::PKey.read(private_key_der(d_bytes)).derive(theirs)
93
+ rescue ::OpenSSL::OpenSSLError => e
94
+ raise EncryptionError, "ECDH failed: #{e.message}"
95
+ end
96
+ raise EncryptionError, "ECDH returned #{secret.bytesize} bytes, expected 32" unless secret.bytesize == 32
97
+
98
+ secret
99
+ end
100
+
101
+ # SubjectPublicKeyInfo (RFC 5480) for the compressed point "02" || x.
102
+ def public_key_der(x_bytes)
103
+ ::OpenSSL::ASN1::Sequence.new(
104
+ [
105
+ ::OpenSSL::ASN1::Sequence.new(
106
+ [::OpenSSL::ASN1::ObjectId.new(EC_PUBLIC_KEY_OID), ::OpenSSL::ASN1::ObjectId.new(SECP256K1_OID)]
107
+ ),
108
+ ::OpenSSL::ASN1::BitString.new("\x02".b + x_bytes)
109
+ ]
110
+ ).to_der
111
+ end
48
112
 
49
- y = p - y if y.odd?
50
- GROUP.new_point([x, y])
113
+ # ECPrivateKey (RFC 5915) holding exactly 32 bytes of scalar, leading
114
+ # zeros included. The optional publicKey field is left out; OpenSSL
115
+ # derives it from the scalar.
116
+ def private_key_der(d_bytes)
117
+ ::OpenSSL::ASN1::Sequence.new(
118
+ [
119
+ ::OpenSSL::ASN1::Integer.new(1),
120
+ ::OpenSSL::ASN1::OctetString.new(d_bytes),
121
+ ::OpenSSL::ASN1::ObjectId.new(SECP256K1_OID, 0, :EXPLICIT)
122
+ ]
123
+ ).to_der
51
124
  end
52
125
  end
53
126
  end
@@ -11,9 +11,17 @@ module NwcRuby
11
11
  # The connection string is missing or malformed.
12
12
  class InvalidConnectionStringError < Error; end
13
13
 
14
- # Encryption / decryption failed (bad MAC, bad padding, unknown version byte).
14
+ # Encryption / decryption failed (bad MAC, bad padding, unknown version
15
+ # byte, or a key that is out of range or not on the curve).
15
16
  class EncryptionError < Error; end
16
17
 
18
+ # This Ruby's OpenSSL cannot do secp256k1 ECDH, which NIP-04 and NIP-44
19
+ # both need: FIPS mode refuses the curve, and some builds leave it out.
20
+ # Unlike `EncryptionError` this is not about one message: nothing can be
21
+ # encrypted or decrypted in this process. `Crypto::ECDH.available?` checks
22
+ # ahead of time.
23
+ class CryptoUnavailableError < Error; end
24
+
17
25
  # Signature verification failed on an inbound event.
18
26
  class InvalidSignatureError < Error; end
19
27
 
@@ -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.5.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'
data/nwc-ruby.gemspec CHANGED
@@ -41,7 +41,6 @@ Gem::Specification.new do |spec|
41
41
  spec.add_dependency 'async-http', '~> 0.70'
42
42
  spec.add_dependency 'async-websocket', '~> 0.26'
43
43
  spec.add_dependency 'base64', '~> 0.2'
44
- spec.add_dependency 'ecdsa', '~> 1.2'
45
44
  spec.add_dependency 'logger', '~> 1.6'
46
45
  spec.add_dependency 'rbsecp256k1', '~> 6.0'
47
46
 
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.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - MegalithicBTC
@@ -65,20 +65,6 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '0.2'
68
- - !ruby/object:Gem::Dependency
69
- name: ecdsa
70
- requirement: !ruby/object:Gem::Requirement
71
- requirements:
72
- - - "~>"
73
- - !ruby/object:Gem::Version
74
- version: '1.2'
75
- type: :runtime
76
- prerelease: false
77
- version_requirements: !ruby/object:Gem::Requirement
78
- requirements:
79
- - - "~>"
80
- - !ruby/object:Gem::Version
81
- version: '1.2'
82
68
  - !ruby/object:Gem::Dependency
83
69
  name: logger
84
70
  requirement: !ruby/object:Gem::Requirement
@@ -183,6 +169,7 @@ files:
183
169
  - lib/nwc_ruby.rb
184
170
  - lib/nwc_ruby/client.rb
185
171
  - lib/nwc_ruby/connection_string.rb
172
+ - lib/nwc_ruby/crypto/conversation.rb
186
173
  - lib/nwc_ruby/crypto/ecdh.rb
187
174
  - lib/nwc_ruby/crypto/keys.rb
188
175
  - lib/nwc_ruby/crypto/schnorr.rb
@@ -222,7 +209,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
222
209
  - !ruby/object:Gem::Version
223
210
  version: '0'
224
211
  requirements: []
225
- rubygems_version: 3.6.8
212
+ rubygems_version: 4.0.23
226
213
  specification_version: 4
227
214
  summary: Ruby client for Nostr Wallet Connect (NIP-47) with safe long-running relay
228
215
  connections.