nwc-ruby 0.2.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 +7 -0
- data/CHANGELOG.md +61 -0
- data/LICENSE +21 -0
- data/README.md +588 -0
- data/lib/nwc-ruby.rb +3 -0
- data/lib/nwc_ruby/client.rb +344 -0
- data/lib/nwc_ruby/connection_string.rb +81 -0
- data/lib/nwc_ruby/crypto/ecdh.rb +54 -0
- data/lib/nwc_ruby/crypto/keys.rb +46 -0
- data/lib/nwc_ruby/crypto/schnorr.rb +38 -0
- data/lib/nwc_ruby/errors.rb +39 -0
- data/lib/nwc_ruby/event.rb +72 -0
- data/lib/nwc_ruby/nip04/cipher.rb +47 -0
- data/lib/nwc_ruby/nip44/cipher.rb +176 -0
- data/lib/nwc_ruby/nip47/info.rb +67 -0
- data/lib/nwc_ruby/nip47/methods.rb +43 -0
- data/lib/nwc_ruby/nip47/notification.rb +50 -0
- data/lib/nwc_ruby/nip47/request.rb +35 -0
- data/lib/nwc_ruby/nip47/response.rb +58 -0
- data/lib/nwc_ruby/test_runner.rb +331 -0
- data/lib/nwc_ruby/transport/relay_connection.rb +240 -0
- data/lib/nwc_ruby/version.rb +5 -0
- data/lib/nwc_ruby.rb +82 -0
- data/nwc-ruby.gemspec +52 -0
- metadata +229 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NwcRuby
|
|
4
|
+
module NIP04
|
|
5
|
+
# NIP-04: legacy DM encryption. AES-256-CBC, PKCS7 padding, random 16-byte IV.
|
|
6
|
+
# Key is the raw ECDH X coordinate (no hashing).
|
|
7
|
+
# Payload format: "<base64_ciphertext>?iv=<base64_iv>"
|
|
8
|
+
#
|
|
9
|
+
# Deprecated in general, but still required for NWC because many wallet
|
|
10
|
+
# services have not migrated: they emit NIP-04 requests/responses (kind
|
|
11
|
+
# 23194/23195) and NIP-04 notifications (kind 23196) alongside or instead
|
|
12
|
+
# of NIP-44 v2.
|
|
13
|
+
module Cipher
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def encrypt(plaintext, privkey_hex, pubkey_hex)
|
|
17
|
+
key = Crypto::ECDH.shared_x(privkey_hex, pubkey_hex)
|
|
18
|
+
iv = SecureRandom.bytes(16)
|
|
19
|
+
|
|
20
|
+
cipher = OpenSSL::Cipher.new('aes-256-cbc').encrypt
|
|
21
|
+
cipher.key = key
|
|
22
|
+
cipher.iv = iv
|
|
23
|
+
ct = cipher.update(plaintext.encode('UTF-8')) + cipher.final
|
|
24
|
+
|
|
25
|
+
"#{Base64.strict_encode64(ct)}?iv=#{Base64.strict_encode64(iv)}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def decrypt(payload, privkey_hex, pubkey_hex)
|
|
29
|
+
raise EncryptionError, 'malformed NIP-04 payload' unless payload.include?('?iv=')
|
|
30
|
+
|
|
31
|
+
ct_b64, iv_b64 = payload.split('?iv=', 2)
|
|
32
|
+
ct = Base64.strict_decode64(ct_b64)
|
|
33
|
+
iv = Base64.strict_decode64(iv_b64)
|
|
34
|
+
raise EncryptionError, 'invalid IV length' unless iv.bytesize == 16
|
|
35
|
+
|
|
36
|
+
key = Crypto::ECDH.shared_x(privkey_hex, pubkey_hex)
|
|
37
|
+
|
|
38
|
+
cipher = OpenSSL::Cipher.new('aes-256-cbc').decrypt
|
|
39
|
+
cipher.key = key
|
|
40
|
+
cipher.iv = iv
|
|
41
|
+
(cipher.update(ct) + cipher.final).force_encoding('UTF-8')
|
|
42
|
+
rescue OpenSSL::Cipher::CipherError, ArgumentError => e
|
|
43
|
+
raise EncryptionError, "NIP-04 decryption failed: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NwcRuby
|
|
4
|
+
module NIP44
|
|
5
|
+
# NIP-44 v2 encryption. The current Nostr DM encryption, and the one NWC
|
|
6
|
+
# wallet services advertise via `["encryption", "nip44_v2"]` on kind 13194.
|
|
7
|
+
#
|
|
8
|
+
# Algorithm (verbatim from the spec):
|
|
9
|
+
# 1. shared_x = X coordinate of ECDH(priv, pub) -- 32 bytes, no hashing
|
|
10
|
+
# 2. conversation_key = HKDF-extract(IKM=shared_x, salt="nip44-v2")
|
|
11
|
+
# 3. keys = HKDF-expand(PRK=conversation_key, info=nonce32, L=76)
|
|
12
|
+
# split into chacha_key[0..32] || chacha_nonce[32..44] || hmac_key[44..76]
|
|
13
|
+
# 4. Plaintext is prefixed with u16-BE length, then zero-padded to a
|
|
14
|
+
# power-of-two chosen by a specific ladder (min 32 bytes, max 65536).
|
|
15
|
+
# 5. Encrypt with plain ChaCha20 (NOT ChaCha20-Poly1305).
|
|
16
|
+
# 6. mac = HMAC-SHA256(hmac_key, ciphertext, aad=nonce)
|
|
17
|
+
# 7. payload = base64( 0x02 || nonce32 || ciphertext || mac32 )
|
|
18
|
+
#
|
|
19
|
+
# Security-critical invariants:
|
|
20
|
+
# - MAC is verified in constant time BEFORE decryption returns plaintext.
|
|
21
|
+
# - Unknown version bytes are rejected.
|
|
22
|
+
# - Padding is validated on decrypt (length prefix sanity + trailing zeros).
|
|
23
|
+
#
|
|
24
|
+
# Reference vectors: https://github.com/paulmillr/nip44/blob/main/nip44.vectors.json
|
|
25
|
+
module Cipher
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
VERSION = 2
|
|
29
|
+
MIN_PLAINTEXT = 1
|
|
30
|
+
MAX_PLAINTEXT = 65_535
|
|
31
|
+
MIN_PADDED_LEN = 32
|
|
32
|
+
SALT = 'nip44-v2'
|
|
33
|
+
|
|
34
|
+
# @param plaintext [String] UTF-8 plaintext (1..65535 bytes)
|
|
35
|
+
# @param privkey_hex [String] our private key, 32-byte hex
|
|
36
|
+
# @param pubkey_hex [String] their x-only pubkey, 32-byte hex
|
|
37
|
+
# @param nonce [String, nil] optional 32-byte nonce (for tests); random if nil
|
|
38
|
+
# @return [String] base64 payload
|
|
39
|
+
def encrypt(plaintext, privkey_hex, pubkey_hex, nonce: nil)
|
|
40
|
+
pt_bytes = plaintext.to_s.dup.force_encoding('UTF-8').b
|
|
41
|
+
if pt_bytes.bytesize < MIN_PLAINTEXT || pt_bytes.bytesize > MAX_PLAINTEXT
|
|
42
|
+
raise EncryptionError, 'plaintext length must be 1..65535 bytes'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
conversation_key = derive_conversation_key(privkey_hex, pubkey_hex)
|
|
46
|
+
nonce ||= SecureRandom.bytes(32)
|
|
47
|
+
raise EncryptionError, 'nonce must be 32 bytes' unless nonce.bytesize == 32
|
|
48
|
+
|
|
49
|
+
chacha_key, chacha_nonce, hmac_key = derive_message_keys(conversation_key, nonce)
|
|
50
|
+
padded = pad(pt_bytes)
|
|
51
|
+
ciphertext = chacha20(chacha_key, chacha_nonce, padded)
|
|
52
|
+
mac = OpenSSL::HMAC.digest('SHA256', hmac_key, nonce + ciphertext)
|
|
53
|
+
|
|
54
|
+
Base64.strict_encode64([VERSION].pack('C') + nonce + ciphertext + mac)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param payload [String] base64 NIP-44 payload
|
|
58
|
+
# @return [String] UTF-8 plaintext
|
|
59
|
+
# rubocop:disable Metrics/AbcSize
|
|
60
|
+
def decrypt(payload, privkey_hex, pubkey_hex)
|
|
61
|
+
raise EncryptionError, 'payload is nil or empty' if payload.nil? || payload.empty?
|
|
62
|
+
raise EncryptionError, "payload starts with '#' (not encrypted)" if payload.start_with?('#')
|
|
63
|
+
|
|
64
|
+
raw = begin
|
|
65
|
+
Base64.strict_decode64(payload)
|
|
66
|
+
rescue ArgumentError
|
|
67
|
+
raise EncryptionError, 'payload is not valid base64'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if raw.bytesize < 1 + 32 + MIN_PADDED_LEN + 32 || raw.bytesize > 1 + 32 + 65_536 + 32
|
|
71
|
+
raise EncryptionError, 'payload length out of range'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
version = raw.byteslice(0, 1).unpack1('C')
|
|
75
|
+
raise EncryptionError, "unsupported NIP-44 version: #{version}" unless version == VERSION
|
|
76
|
+
|
|
77
|
+
nonce = raw.byteslice(1, 32)
|
|
78
|
+
mac = raw.byteslice(raw.bytesize - 32, 32)
|
|
79
|
+
ciphertext = raw.byteslice(33, raw.bytesize - 33 - 32)
|
|
80
|
+
|
|
81
|
+
conversation_key = derive_conversation_key(privkey_hex, pubkey_hex)
|
|
82
|
+
chacha_key, chacha_nonce, hmac_key = derive_message_keys(conversation_key, nonce)
|
|
83
|
+
|
|
84
|
+
expected_mac = OpenSSL::HMAC.digest('SHA256', hmac_key, nonce + ciphertext)
|
|
85
|
+
raise EncryptionError, 'NIP-44 MAC verification failed' unless secure_compare(mac, expected_mac)
|
|
86
|
+
|
|
87
|
+
padded = chacha20(chacha_key, chacha_nonce, ciphertext)
|
|
88
|
+
unpad(padded).force_encoding('UTF-8')
|
|
89
|
+
end
|
|
90
|
+
# rubocop:enable Metrics/AbcSize
|
|
91
|
+
|
|
92
|
+
# --- Internals ------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def derive_conversation_key(privkey_hex, pubkey_hex)
|
|
95
|
+
shared = Crypto::ECDH.shared_x(privkey_hex, pubkey_hex)
|
|
96
|
+
hkdf_extract(SALT.b, shared)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def derive_message_keys(conversation_key, nonce)
|
|
100
|
+
keys = hkdf_expand(conversation_key, nonce, 76)
|
|
101
|
+
[keys.byteslice(0, 32), keys.byteslice(32, 12), keys.byteslice(44, 32)]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# HKDF-extract(salt, IKM) = HMAC-SHA256(salt, IKM). Returns 32 bytes.
|
|
105
|
+
def hkdf_extract(salt, ikm)
|
|
106
|
+
OpenSSL::HMAC.digest('SHA256', salt, ikm)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# HKDF-expand(PRK, info, L). RFC 5869.
|
|
110
|
+
def hkdf_expand(prk, info, length)
|
|
111
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
112
|
+
t = String.new(encoding: Encoding::BINARY)
|
|
113
|
+
counter = 1
|
|
114
|
+
while out.bytesize < length
|
|
115
|
+
t = OpenSSL::HMAC.digest('SHA256', prk, t + info + [counter].pack('C'))
|
|
116
|
+
out << t
|
|
117
|
+
counter += 1
|
|
118
|
+
end
|
|
119
|
+
out.byteslice(0, length)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Plain ChaCha20, 20 rounds, 96-bit nonce, 256-bit key.
|
|
123
|
+
def chacha20(key, nonce, data)
|
|
124
|
+
cipher = OpenSSL::Cipher.new('chacha20')
|
|
125
|
+
cipher.encrypt
|
|
126
|
+
# OpenSSL's "chacha20" wants a 16-byte IV: 4-byte counter (little-endian, 0) + 12-byte nonce.
|
|
127
|
+
cipher.key = key
|
|
128
|
+
cipher.iv = [0].pack('V') + nonce
|
|
129
|
+
cipher.update(data) + cipher.final
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Pad plaintext: prepend u16-BE length, then zero-pad to `calc_padded_len(n)`.
|
|
133
|
+
def pad(pt_bytes)
|
|
134
|
+
n = pt_bytes.bytesize
|
|
135
|
+
padded_len = calc_padded_len(n)
|
|
136
|
+
prefix = [n].pack('n') # u16 big-endian
|
|
137
|
+
zeros = "\x00".b * (padded_len - n)
|
|
138
|
+
prefix + pt_bytes + zeros
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Spec's ladder: minimum 32, then power-of-two chunks.
|
|
142
|
+
def calc_padded_len(unpadded_len)
|
|
143
|
+
return MIN_PADDED_LEN if unpadded_len <= MIN_PADDED_LEN
|
|
144
|
+
|
|
145
|
+
# next_power = 1 << (ceil(log2(unpadded_len - 1)))
|
|
146
|
+
next_power = 1 << (unpadded_len - 1).bit_length
|
|
147
|
+
chunk = next_power <= 256 ? 32 : next_power / 8
|
|
148
|
+
(((unpadded_len - 1) / chunk) + 1) * chunk
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def unpad(padded)
|
|
152
|
+
raise EncryptionError, 'padded data too short' if padded.bytesize < 2 + MIN_PADDED_LEN - 2
|
|
153
|
+
|
|
154
|
+
n = padded.byteslice(0, 2).unpack1('n')
|
|
155
|
+
raise EncryptionError, 'invalid padding length' if n < MIN_PLAINTEXT || n > MAX_PLAINTEXT
|
|
156
|
+
|
|
157
|
+
pt = padded.byteslice(2, n)
|
|
158
|
+
raise EncryptionError, 'truncated padded plaintext' if pt.nil? || pt.bytesize != n
|
|
159
|
+
|
|
160
|
+
expected_total = 2 + calc_padded_len(n)
|
|
161
|
+
raise EncryptionError, 'padded length mismatch' unless padded.bytesize == expected_total
|
|
162
|
+
|
|
163
|
+
pt
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Constant-time byte comparison.
|
|
167
|
+
def secure_compare(a, b)
|
|
168
|
+
return false unless a.bytesize == b.bytesize
|
|
169
|
+
|
|
170
|
+
acc = 0
|
|
171
|
+
a.bytes.zip(b.bytes) { |x, y| acc |= x ^ y }
|
|
172
|
+
acc.zero?
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NwcRuby
|
|
4
|
+
module NIP47
|
|
5
|
+
# Parses the kind 13194 "info" event — the wallet service's capability
|
|
6
|
+
# advertisement. The `content` is a plaintext space-separated list of
|
|
7
|
+
# supported methods. Tags optionally include:
|
|
8
|
+
# ["encryption", "nip44_v2 nip04"] -- supported encryption schemes
|
|
9
|
+
# ["notifications", "payment_received payment_sent"]
|
|
10
|
+
class Info
|
|
11
|
+
attr_reader :methods, :encryption_schemes, :notification_types, :event
|
|
12
|
+
|
|
13
|
+
def initialize(methods:, encryption_schemes:, notification_types:, event:)
|
|
14
|
+
@methods = methods
|
|
15
|
+
@encryption_schemes = encryption_schemes
|
|
16
|
+
@notification_types = notification_types
|
|
17
|
+
@event = event
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.parse(event)
|
|
21
|
+
methods = event.content.to_s.strip.split(/\s+/).reject(&:empty?)
|
|
22
|
+
|
|
23
|
+
enc_tag = event.tags.find { |t| t[0] == 'encryption' }
|
|
24
|
+
schemes = if enc_tag
|
|
25
|
+
enc_tag[1].to_s.strip.split(/\s+/)
|
|
26
|
+
else
|
|
27
|
+
# Spec: absence means nip04 only.
|
|
28
|
+
['nip04']
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
notif_tag = event.tags.find { |t| t[0] == 'notifications' }
|
|
32
|
+
notif_types = notif_tag ? notif_tag[1].to_s.strip.split(/\s+/) : []
|
|
33
|
+
|
|
34
|
+
new(methods: methods, encryption_schemes: schemes, notification_types: notif_types, event: event)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def supports?(method)
|
|
38
|
+
@methods.include?(method)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def supports_nip44?
|
|
42
|
+
@encryption_schemes.include?('nip44_v2')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def supports_nip04?
|
|
46
|
+
@encryption_schemes.include?('nip04')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def preferred_encryption
|
|
50
|
+
supports_nip44? ? :nip44_v2 : :nip04
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# True when the connection exposes no fund-moving methods.
|
|
54
|
+
def read_only?
|
|
55
|
+
!@methods.intersect?(Methods::MUTATING)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def read_write?
|
|
59
|
+
!read_only?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def supports_notifications?
|
|
63
|
+
!@notification_types.empty?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NwcRuby
|
|
4
|
+
module NIP47
|
|
5
|
+
# NIP-47 kinds and methods.
|
|
6
|
+
module Methods
|
|
7
|
+
# Event kinds.
|
|
8
|
+
KIND_INFO = 13_194 # replaceable, plaintext capability list
|
|
9
|
+
KIND_REQUEST = 23_194 # client -> wallet, encrypted
|
|
10
|
+
KIND_RESPONSE = 23_195 # wallet -> client, encrypted
|
|
11
|
+
KIND_NOTIFICATION_NIP04 = 23_196 # wallet -> client, NIP-04 encrypted
|
|
12
|
+
KIND_NOTIFICATION_NIP44 = 23_197 # wallet -> client, NIP-44 v2 encrypted
|
|
13
|
+
|
|
14
|
+
# Methods (the payload `method` field). The info event advertises a
|
|
15
|
+
# space-separated subset of these.
|
|
16
|
+
PAY_INVOICE = 'pay_invoice'
|
|
17
|
+
MULTI_PAY_INVOICE = 'multi_pay_invoice'
|
|
18
|
+
PAY_KEYSEND = 'pay_keysend'
|
|
19
|
+
MULTI_PAY_KEYSEND = 'multi_pay_keysend'
|
|
20
|
+
MAKE_INVOICE = 'make_invoice'
|
|
21
|
+
LOOKUP_INVOICE = 'lookup_invoice'
|
|
22
|
+
LIST_TRANSACTIONS = 'list_transactions'
|
|
23
|
+
GET_BALANCE = 'get_balance'
|
|
24
|
+
GET_INFO = 'get_info'
|
|
25
|
+
SIGN_MESSAGE = 'sign_message'
|
|
26
|
+
NOTIFICATIONS = 'notifications' # capability marker, not a method
|
|
27
|
+
|
|
28
|
+
ALL = [
|
|
29
|
+
PAY_INVOICE, MULTI_PAY_INVOICE, PAY_KEYSEND, MULTI_PAY_KEYSEND,
|
|
30
|
+
MAKE_INVOICE, LOOKUP_INVOICE, LIST_TRANSACTIONS,
|
|
31
|
+
GET_BALANCE, GET_INFO, SIGN_MESSAGE
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
# The set of methods that can move funds. A connection without any of
|
|
35
|
+
# these is "read-only".
|
|
36
|
+
MUTATING = [
|
|
37
|
+
PAY_INVOICE, MULTI_PAY_INVOICE, PAY_KEYSEND, MULTI_PAY_KEYSEND
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
NOTIFICATION_TYPES = %w[payment_received payment_sent].freeze
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NwcRuby
|
|
4
|
+
module NIP47
|
|
5
|
+
# Parses kind 23196 (NIP-04) or 23197 (NIP-44 v2) notification events.
|
|
6
|
+
#
|
|
7
|
+
# Inner payload shape:
|
|
8
|
+
# { "notification_type": "payment_received"|"payment_sent",
|
|
9
|
+
# "notification": { ...transaction fields... } }
|
|
10
|
+
class Notification
|
|
11
|
+
attr_reader :type, :data, :event
|
|
12
|
+
|
|
13
|
+
def initialize(type:, data:, event:)
|
|
14
|
+
@type = type
|
|
15
|
+
@data = data
|
|
16
|
+
@event = event
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def payment_hash
|
|
20
|
+
@data['payment_hash']
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def amount_msats
|
|
24
|
+
@data['amount']
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def payment_received?
|
|
28
|
+
@type == 'payment_received'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def payment_sent?
|
|
32
|
+
@type == 'payment_sent'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.parse(event, client_privkey, wallet_pubkey)
|
|
36
|
+
plaintext = case event.kind
|
|
37
|
+
when Methods::KIND_NOTIFICATION_NIP44
|
|
38
|
+
NIP44::Cipher.decrypt(event.content, client_privkey, wallet_pubkey)
|
|
39
|
+
when Methods::KIND_NOTIFICATION_NIP04
|
|
40
|
+
NIP04::Cipher.decrypt(event.content, client_privkey, wallet_pubkey)
|
|
41
|
+
else
|
|
42
|
+
raise ArgumentError, "not a notification kind: #{event.kind}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
data = JSON.parse(plaintext)
|
|
46
|
+
new(type: data['notification_type'], data: data['notification'] || {}, event: event)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NwcRuby
|
|
4
|
+
module NIP47
|
|
5
|
+
# Builds a signed kind 23194 request event with the JSON-RPC payload
|
|
6
|
+
# encrypted using either NIP-44 v2 or NIP-04.
|
|
7
|
+
module Request
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# @param method [String] NIP-47 method name
|
|
11
|
+
# @param params [Hash] method-specific params
|
|
12
|
+
# @param client_privkey [String] hex
|
|
13
|
+
# @param wallet_pubkey [String] hex
|
|
14
|
+
# @param encryption [Symbol] :nip44_v2 or :nip04
|
|
15
|
+
# @param expiration [Integer, nil] optional unix timestamp
|
|
16
|
+
# @return [Event]
|
|
17
|
+
def build(method:, params:, client_privkey:, wallet_pubkey:, encryption: :nip44_v2, expiration: nil)
|
|
18
|
+
payload = JSON.generate({ 'method' => method, 'params' => params })
|
|
19
|
+
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)
|
|
22
|
+
else raise ArgumentError, "unknown encryption: #{encryption}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
tags = [['p', wallet_pubkey]]
|
|
26
|
+
tags << %w[encryption nip44_v2] if encryption == :nip44_v2
|
|
27
|
+
tags << ['expiration', expiration.to_s] if expiration
|
|
28
|
+
|
|
29
|
+
client_pubkey = Crypto::Keys.public_key_from_private(client_privkey)
|
|
30
|
+
event = Event.new(pubkey: client_pubkey, kind: Methods::KIND_REQUEST, content: ciphertext, tags: tags)
|
|
31
|
+
event.sign!(client_privkey)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NwcRuby
|
|
4
|
+
module NIP47
|
|
5
|
+
# Parses an incoming kind 23195 response event.
|
|
6
|
+
class Response
|
|
7
|
+
attr_reader :result_type, :result, :error, :request_id, :event
|
|
8
|
+
|
|
9
|
+
def initialize(result_type:, result:, error:, request_id:, event:)
|
|
10
|
+
@result_type = result_type
|
|
11
|
+
@result = result
|
|
12
|
+
@error = error
|
|
13
|
+
@request_id = request_id
|
|
14
|
+
@event = event
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def success?
|
|
18
|
+
@error.nil? && !@result.nil?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def error_code
|
|
22
|
+
@error && @error['code']
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def error_message
|
|
26
|
+
@error && @error['message']
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param event [Event] the kind 23195 event (already signature-verified)
|
|
30
|
+
# @param client_privkey [String] hex
|
|
31
|
+
# @param wallet_pubkey [String] hex
|
|
32
|
+
# @return [Response]
|
|
33
|
+
def self.parse(event, client_privkey, wallet_pubkey)
|
|
34
|
+
# Tags: ["p", client_pubkey], ["e", request_event_id]
|
|
35
|
+
e_tag = event.tags.find { |t| t[0] == 'e' }
|
|
36
|
+
request_id = e_tag && e_tag[1]
|
|
37
|
+
|
|
38
|
+
plaintext = decrypt(event.content, client_privkey, wallet_pubkey)
|
|
39
|
+
data = JSON.parse(plaintext)
|
|
40
|
+
|
|
41
|
+
new(
|
|
42
|
+
result_type: data['result_type'],
|
|
43
|
+
result: data['result'],
|
|
44
|
+
error: data['error'],
|
|
45
|
+
request_id: request_id,
|
|
46
|
+
event: event
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Try NIP-44 v2 first; fall back to NIP-04 if the version byte is wrong.
|
|
51
|
+
def self.decrypt(content, client_privkey, wallet_pubkey)
|
|
52
|
+
NIP44::Cipher.decrypt(content, client_privkey, wallet_pubkey)
|
|
53
|
+
rescue EncryptionError
|
|
54
|
+
NIP04::Cipher.decrypt(content, client_privkey, wallet_pubkey)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|