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.
@@ -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