nwc-ruby 0.4.0 → 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: eb3477afba784c35d4e2db68ea408f30efec1330516c24f368471b123dcc7ddc
4
- data.tar.gz: aabf55cb7cbeb1263945e457aab8f2908f39e560f0297d7406adc31ba7e56a73
3
+ metadata.gz: e2d94a88c6465f4422539d861f346b8d7ab7726d2805be0c4b9d0705b6dd9399
4
+ data.tar.gz: ae16489410ce6cb3663a93c3aa5e20b84ff9c920294cebb5ab690a00f12da1f0
5
5
  SHA512:
6
- metadata.gz: 9c52a015142aec41f912b9f3881f56e5ce8289be0106f000be2786677320426e250ac73b70a9905c691e082ac293286caf169aac453af92886380053b7e34461
7
- data.tar.gz: 73035a32f7fe34602f527f44941703ebecf7c505319f5e4899e8a6168350b2ae5d108f3be1e8c661d2854b05c087e752f44d6960a9f401ac880be0776d6a6166
6
+ metadata.gz: d91f00c5dc93b17b8c6eb9cbcdc0e3a31175fcd1b0ddcb843bd3fd3a58948cdbe1844a5f0dcf3e363915eb6339071b9f3691c564f66362eb96f3431dea1b3db3
7
+ data.tar.gz: 3169a1dd02f32a5bbefd6c03b037e35acbd3773922d88c145fad408b8fc18d4bd5a1b4c26e5aed34909a036750c81f64db4264e38cfca5367516796ee0b23a65
data/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ 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
+
10
50
  ## [0.4.0] — 2026-10-20
11
51
 
12
52
  ### Changed
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
@@ -529,9 +538,9 @@ 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
 
531
540
  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.
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.
535
544
 
536
545
  Introspection:
537
546
 
@@ -582,6 +591,9 @@ the original is kept as `#cause`.
582
591
 
583
592
  - `InvalidConnectionStringError` — the URI couldn't be parsed.
584
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.
585
597
  - `InvalidSignatureError` — an event's signature did not verify.
586
598
  - `TransportError` — the connection failed **after** the request was written
587
599
  to the relay. The wallet may have acted on it: reconcile with
@@ -4,11 +4,11 @@ module NwcRuby
4
4
  module Crypto
5
5
  # The symmetric keys for one (our private key, their public key) pair.
6
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.
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
12
  #
13
13
  # Nothing is computed in `initialize`, so a bad key raises at first use,
14
14
  # exactly where the uncached path raises it, and a failure is not cached.
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NwcRuby
4
- VERSION = '0.4.0'
4
+ VERSION = '0.5.0'
5
5
  end
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.4.0
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
@@ -223,7 +209,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
223
209
  - !ruby/object:Gem::Version
224
210
  version: '0'
225
211
  requirements: []
226
- rubygems_version: 3.6.9
212
+ rubygems_version: 4.0.23
227
213
  specification_version: 4
228
214
  summary: Ruby client for Nostr Wallet Connect (NIP-47) with safe long-running relay
229
215
  connections.