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 +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +15 -3
- data/lib/nwc_ruby/crypto/conversation.rb +5 -5
- data/lib/nwc_ruby/crypto/ecdh.rb +93 -20
- data/lib/nwc_ruby/errors.rb +9 -1
- data/lib/nwc_ruby/version.rb +1 -1
- data/nwc-ruby.gemspec +0 -1
- metadata +2 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2d94a88c6465f4422539d861f346b8d7ab7726d2805be0c4b9d0705b6dd9399
|
|
4
|
+
data.tar.gz: ae16489410ce6cb3663a93c3aa5e20b84ff9c920294cebb5ab690a00f12da1f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
533
|
-
|
|
534
|
-
pays
|
|
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,
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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.
|
data/lib/nwc_ruby/crypto/ecdh.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
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).
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
data/lib/nwc_ruby/errors.rb
CHANGED
|
@@ -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
|
|
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
|
|
data/lib/nwc_ruby/version.rb
CHANGED
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
|
+
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:
|
|
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.
|