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 +4 -4
- data/CHANGELOG.md +71 -0
- data/README.md +17 -0
- data/lib/nwc_ruby/client.rb +6 -3
- data/lib/nwc_ruby/connection_string.rb +7 -0
- data/lib/nwc_ruby/crypto/conversation.rb +32 -0
- data/lib/nwc_ruby/crypto/ecdh.rb +93 -20
- data/lib/nwc_ruby/errors.rb +9 -1
- data/lib/nwc_ruby/nip04/cipher.rb +18 -6
- data/lib/nwc_ruby/nip44/cipher.rb +20 -6
- data/lib/nwc_ruby/nip47/notification.rb +6 -3
- data/lib/nwc_ruby/nip47/request.rb +7 -3
- data/lib/nwc_ruby/nip47/response.rb +14 -5
- data/lib/nwc_ruby/version.rb +1 -1
- data/lib/nwc_ruby.rb +1 -0
- data/nwc-ruby.gemspec +0 -1
- metadata +3 -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,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
|
data/lib/nwc_ruby/client.rb
CHANGED
|
@@ -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
|
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
|
|
|
@@ -14,11 +14,17 @@ module NwcRuby
|
|
|
14
14
|
module_function
|
|
15
15
|
|
|
16
16
|
def encrypt(plaintext, privkey_hex, pubkey_hex)
|
|
17
|
-
|
|
18
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
41
|
+
NIP44::Cipher.decrypt_with(event.content, conversation)
|
|
39
42
|
when Methods::KIND_NOTIFICATION_NIP04
|
|
40
|
-
NIP04::Cipher.
|
|
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.
|
|
21
|
-
when :nip04 then NIP04::Cipher.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
63
|
+
NIP04::Cipher.decrypt_with(content, conversation)
|
|
55
64
|
end
|
|
56
65
|
end
|
|
57
66
|
end
|
data/lib/nwc_ruby/version.rb
CHANGED
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.
|
|
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:
|
|
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.
|