bsv-sdk 0.3.1 → 0.3.2

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: b45240189e3a6531b7273ee4df823490c1f892ae332257743b74783f2e88ef87
4
- data.tar.gz: b3ffa3feb075fb232b87a50149af07139ef01d1d80edb03c7e595f758fe01a84
3
+ metadata.gz: 9924711798c6f50752254b528fb1956bcc39e3cf5cf1b57fdf94a887a3dc9688
4
+ data.tar.gz: d3cd183e1ccdafadbe37b989275cd03d4c505e2432b3ede6d1af5213b7455361
5
5
  SHA512:
6
- metadata.gz: 28cb3358d0483ad4574e1ef3a57c6f85f45e4404de0a70faea6e8b431b9c47655b490e73f4779ca74a617e35bcdadd65eb7f5e2cbd9310199a3af494af4a9187
7
- data.tar.gz: 5a4f40bf35ba4dc93a260c6729871a165ebb4e468b802f664371bcf0574d3aacf23d91f8ca9634a739af6cd4443e8b264b9256a2888ddfd725289f98a606b3a9
6
+ metadata.gz: 88f3ed7869e81aec560b47a405e9144d00e3e48cf4b62d5be6bd09e58ac216ebbe8b56ebcff2ac61075c901d56308875b7ff4673d2e066df74f723e6391f3ea5
7
+ data.tar.gz: a88d52282aa5d2a05b906685245b6690c434dbea73fb01ed99be1dc93a4706720c13ee5fb48830a4c3c423c9160db264fe66c910de5995c94047f61e7f56120d
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Auth
5
+ # Raised when a BRC-31 authentication protocol error occurs.
6
+ class AuthError < StandardError; end
7
+ end
8
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'securerandom'
5
+
6
+ module BSV
7
+ module Auth
8
+ # Nonce creation and verification for BRC-31 mutual authentication.
9
+ #
10
+ # A nonce is a 48-byte value: 16 random bytes concatenated with a 32-byte
11
+ # HMAC-SHA256 over those 16 bytes, base64-encoded. The HMAC is computed
12
+ # with the wallet using protocol [2, 'server hmac'] and the raw bytes as
13
+ # the key ID (decoded to a string). This makes nonces self-authenticating —
14
+ # only the wallet that created a nonce can verify it.
15
+ #
16
+ # The key ID is derived by decoding the 16 random bytes as UTF-8, replacing
17
+ # any invalid byte sequences with the Unicode replacement character (U+FFFD).
18
+ # This matches the ts-sdk behaviour (TextDecoder in non-fatal replacement mode).
19
+ # Since nonces are always self-verified (counterparty='self'), the key ID
20
+ # encoding does not need to be interoperable across SDK implementations.
21
+ module Nonce
22
+ PROTOCOL_ID = [2, 'server hmac'].freeze
23
+ RANDOM_BYTES = 16
24
+
25
+ module_function
26
+
27
+ # Creates a self-authenticating nonce.
28
+ #
29
+ # @param wallet [BSV::Wallet::Interface] wallet with HMAC capability
30
+ # @param counterparty [String] counterparty ('self', 'anyone', or public key hex).
31
+ # Defaults to 'self' — nonces are self-verified by the creating wallet.
32
+ # @return [String] base64-encoded nonce (48 bytes: 16 random + 32 HMAC)
33
+ def create(wallet, counterparty = 'self')
34
+ first_half = SecureRandom.random_bytes(RANDOM_BYTES)
35
+ key_id = decode_as_utf8(first_half)
36
+
37
+ result = wallet.create_hmac({
38
+ data: first_half.bytes,
39
+ protocol_id: PROTOCOL_ID,
40
+ key_id: key_id,
41
+ counterparty: counterparty
42
+ })
43
+
44
+ nonce_bytes = first_half + result[:hmac].pack('C*')
45
+ ::Base64.strict_encode64(nonce_bytes)
46
+ end
47
+
48
+ # Verifies that a nonce was created by the given wallet.
49
+ #
50
+ # @param nonce [String] base64-encoded nonce to verify
51
+ # @param wallet [BSV::Wallet::Interface] wallet
52
+ # @param counterparty [String] counterparty — must match the value used
53
+ # when the nonce was created (typically 'self')
54
+ # @return [Boolean] true if the nonce is valid
55
+ def verify(nonce, wallet, counterparty = 'self')
56
+ nonce_bytes = ::Base64.strict_decode64(nonce)
57
+ return false if nonce_bytes.bytesize <= RANDOM_BYTES
58
+
59
+ first_half = nonce_bytes.byteslice(0, RANDOM_BYTES)
60
+ hmac_bytes = nonce_bytes.byteslice(RANDOM_BYTES, nonce_bytes.bytesize - RANDOM_BYTES)
61
+ key_id = decode_as_utf8(first_half)
62
+
63
+ wallet.verify_hmac({
64
+ data: first_half.bytes,
65
+ hmac: hmac_bytes.bytes,
66
+ protocol_id: PROTOCOL_ID,
67
+ key_id: key_id,
68
+ counterparty: counterparty
69
+ })
70
+
71
+ true
72
+ rescue BSV::Wallet::InvalidHmacError
73
+ false
74
+ end
75
+
76
+ # Decodes a binary string as UTF-8, replacing invalid byte sequences with
77
+ # the Unicode replacement character (U+FFFD). Used to derive the HMAC key
78
+ # ID from the random nonce bytes in a way consistent with the ts-sdk's
79
+ # use of TextDecoder (non-fatal mode).
80
+ #
81
+ # @param bytes [String] binary (ASCII-8BIT) string
82
+ # @return [String] UTF-8 encoded string
83
+ def decode_as_utf8(bytes)
84
+ bytes.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: "\uFFFD")
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module BSV
6
+ module Auth
7
+ # BRC-31/BRC-103 mutual authentication peer.
8
+ #
9
+ # Manages the cryptographic handshake between two parties:
10
+ #
11
+ # 1. Alice calls {#initiate_handshake} — sends +initialRequest+ with her
12
+ # session nonce and requested certificates.
13
+ # 2. Bob calls {#handle_incoming_message} — processes the request, creates
14
+ # his own session nonce, signs +decode(alice_nonce) + decode(bob_nonce)+,
15
+ # and sends +initialResponse+.
16
+ # 3. Alice processes +initialResponse+ — verifies the nonce is hers
17
+ # (via HMAC), verifies Bob's signature, and marks the session authenticated.
18
+ # 4. Both parties may now exchange authenticated +general+ messages.
19
+ #
20
+ # Sessions are indexed by +session_nonce+ (our nonce), which is the primary
21
+ # key in the {SessionManager}.
22
+ #
23
+ # @example Using Peer with a custom transport
24
+ # wallet_a = BSV::Wallet::ProtoWallet.new(BSV::Primitives::PrivateKey.generate)
25
+ # wallet_b = BSV::Wallet::ProtoWallet.new(BSV::Primitives::PrivateKey.generate)
26
+ #
27
+ # # Wire the two peers together with synchronous in-process transports
28
+ # peer_a = BSV::Auth::Peer.new(wallet: wallet_a)
29
+ # peer_b = BSV::Auth::Peer.new(wallet: wallet_b)
30
+ #
31
+ # # Alice initiates; Bob responds; Alice completes
32
+ # request = peer_a.create_initial_request
33
+ # response = peer_b.handle_incoming_message(request)
34
+ # peer_a.handle_incoming_message(response)
35
+ class Peer
36
+ AUTH_PROTOCOL = [2, 'auth message signature'].freeze
37
+
38
+ attr_reader :wallet, :session_manager
39
+
40
+ # @param wallet [BSV::Wallet::Interface] wallet providing crypto operations
41
+ # @param transport [Transport, nil] optional transport for async usage
42
+ # @param session_manager [SessionManager, nil] optional custom session store
43
+ # @param certificates_to_request [Hash, nil] certificate set to request from peers
44
+ def initialize(wallet:, transport: nil, session_manager: nil, certificates_to_request: nil)
45
+ @wallet = wallet
46
+ @transport = transport
47
+ @session_manager = session_manager || SessionManager.new
48
+ @certificates_to_request = certificates_to_request || { certifiers: [], types: {} }
49
+ @identity_public_key = nil
50
+
51
+ return unless @transport
52
+
53
+ @transport.on_data { |msg| handle_incoming_message(msg) }
54
+ end
55
+
56
+ # Returns our identity key (cached after first call).
57
+ #
58
+ # @return [String] compressed public key hex
59
+ def identity_key
60
+ @identity_key ||= @wallet.get_public_key({ identity_key: true })[:public_key]
61
+ end
62
+
63
+ # Checks whether we have an authenticated session with a peer.
64
+ #
65
+ # Accepts either a +session_nonce+ or +peer_identity_key+.
66
+ #
67
+ # @param identifier [String]
68
+ # @return [Boolean]
69
+ def authenticated?(identifier)
70
+ session = @session_manager.get_session(identifier)
71
+ session&.authenticated? || false
72
+ end
73
+
74
+ # --- Handshake initiation (we are the initiator) ---
75
+
76
+ # Builds an +initialRequest+ message and creates a pending session.
77
+ #
78
+ # @return [Hash] the message to send to the peer
79
+ def create_initial_request
80
+ session_nonce = Nonce.create(@wallet)
81
+
82
+ session = PeerSession.new(session_nonce: session_nonce)
83
+ session.certificates_required = certifiers_present?
84
+ session.certificates_validated = !session.certificates_required
85
+
86
+ @session_manager.add_session(session)
87
+
88
+ {
89
+ version: AUTH_VERSION,
90
+ message_type: MSG_INITIAL_REQUEST,
91
+ identity_key: identity_key,
92
+ initial_nonce: session_nonce,
93
+ requested_certificates: @certificates_to_request
94
+ }
95
+ end
96
+
97
+ # --- Incoming message dispatch ---
98
+
99
+ # Processes any incoming auth message and returns a response (if applicable).
100
+ #
101
+ # @param message [Hash] the incoming auth message
102
+ # @return [Hash, nil] response message, or nil for messages requiring no reply
103
+ # @raise [AuthError] if the version is wrong or the message type is unknown
104
+ def handle_incoming_message(message)
105
+ version = message[:version] || message['version']
106
+ raise AuthError, "Unsupported auth version: #{version.inspect} (expected #{AUTH_VERSION})" unless version == AUTH_VERSION
107
+
108
+ type = message[:message_type] || message['message_type']
109
+
110
+ case type
111
+ when MSG_INITIAL_REQUEST then process_initial_request(message)
112
+ when MSG_INITIAL_RESPONSE then process_initial_response(message)
113
+ when MSG_GENERAL then process_general_message(message)
114
+ else
115
+ raise AuthError, "Unknown message type: #{type.inspect}"
116
+ end
117
+ end
118
+
119
+ # --- General message exchange (post-handshake) ---
120
+
121
+ # Creates an authenticated +general+ message to send to a peer.
122
+ #
123
+ # @param peer_identifier [String] peer's session nonce or identity key
124
+ # @param payload [Array<Integer>] byte array payload
125
+ # @return [Hash] the message to send
126
+ # @raise [AuthError] if not authenticated with this peer
127
+ def create_general_message(peer_identifier, payload)
128
+ session = @session_manager.get_session(peer_identifier)
129
+ raise AuthError, "Not authenticated with peer #{peer_identifier.inspect}" unless session&.authenticated?
130
+
131
+ # The receiver verifies `your_nonce` using their own wallet — so it must
132
+ # be the peer's nonce (the nonce THEY created during the handshake).
133
+ request_nonce = ::Base64.strict_encode64(SecureRandom.random_bytes(32))
134
+ key_id = key_id_for(request_nonce, session.peer_nonce)
135
+
136
+ sig_result = @wallet.create_signature({
137
+ data: payload,
138
+ protocol_id: AUTH_PROTOCOL,
139
+ key_id: key_id,
140
+ counterparty: session.peer_identity_key
141
+ })
142
+
143
+ session.last_update = current_time_ms
144
+ @session_manager.update_session(session)
145
+
146
+ {
147
+ version: AUTH_VERSION,
148
+ message_type: MSG_GENERAL,
149
+ identity_key: identity_key,
150
+ nonce: request_nonce,
151
+ your_nonce: session.peer_nonce,
152
+ payload: payload,
153
+ signature: sig_result[:signature]
154
+ }
155
+ end
156
+
157
+ private
158
+
159
+ # --- Processing: initial request (we are the responder) ---
160
+
161
+ def process_initial_request(message)
162
+ peer_key = fetch!(message, :identity_key)
163
+ their_nonce = fetch!(message, :initial_nonce)
164
+
165
+ raise AuthError, 'initial_nonce must not be empty' if their_nonce.empty?
166
+
167
+ our_nonce = Nonce.create(@wallet)
168
+
169
+ session = PeerSession.new(session_nonce: our_nonce)
170
+ session.peer_identity_key = peer_key
171
+ session.peer_nonce = their_nonce
172
+ session.is_authenticated = true
173
+ session.certificates_required = certifiers_present?
174
+ session.certificates_validated = !session.certificates_required
175
+ session.last_update = current_time_ms
176
+
177
+ @session_manager.add_session(session)
178
+
179
+ # Sign decode(their_nonce) + decode(our_nonce)
180
+ # Key ID: "their_nonce our_nonce" (initiator_nonce responder_nonce)
181
+ sig_data = b64_decode(their_nonce) + b64_decode(our_nonce)
182
+ key_id = key_id_for(their_nonce, our_nonce)
183
+
184
+ sig_result = @wallet.create_signature({
185
+ data: sig_data,
186
+ protocol_id: AUTH_PROTOCOL,
187
+ key_id: key_id,
188
+ counterparty: peer_key
189
+ })
190
+
191
+ {
192
+ version: AUTH_VERSION,
193
+ message_type: MSG_INITIAL_RESPONSE,
194
+ identity_key: identity_key,
195
+ initial_nonce: our_nonce,
196
+ your_nonce: their_nonce,
197
+ requested_certificates: @certificates_to_request,
198
+ signature: sig_result[:signature]
199
+ }
200
+ end
201
+
202
+ # --- Processing: initial response (we are the initiator) ---
203
+
204
+ def process_initial_response(message)
205
+ their_nonce = fetch!(message, :initial_nonce) # responder's nonce
206
+ our_nonce = fetch!(message, :your_nonce) # our nonce echoed back
207
+ peer_key = fetch!(message, :identity_key)
208
+ signature = fetch!(message, :signature)
209
+
210
+ # Verify the echoed nonce is one we created
211
+ raise AuthError, "Nonce verification failed from peer: #{peer_key}" unless Nonce.verify(our_nonce, @wallet)
212
+
213
+ session = @session_manager.get_session(our_nonce)
214
+ raise AuthError, "No pending session for nonce: #{our_nonce.inspect}" unless session
215
+
216
+ # Verify responder's signature over decode(our_nonce) + decode(their_nonce)
217
+ # Key ID: "our_nonce their_nonce" (initiator_nonce responder_nonce)
218
+ sig_data = b64_decode(our_nonce) + b64_decode(their_nonce)
219
+ key_id = key_id_for(our_nonce, their_nonce)
220
+
221
+ @wallet.verify_signature({
222
+ data: sig_data,
223
+ signature: signature,
224
+ protocol_id: AUTH_PROTOCOL,
225
+ key_id: key_id,
226
+ counterparty: peer_key
227
+ })
228
+
229
+ # Authentication complete
230
+ session.peer_nonce = their_nonce
231
+ session.peer_identity_key = peer_key
232
+ session.is_authenticated = true
233
+ session.last_update = current_time_ms
234
+
235
+ @session_manager.update_session(session)
236
+
237
+ nil
238
+ rescue BSV::Wallet::InvalidSignatureError
239
+ @session_manager.remove_session(session) if session
240
+ raise AuthError, "Initial response signature verification failed from peer: #{peer_key}"
241
+ end
242
+
243
+ # --- Processing: general message (we are the receiver) ---
244
+
245
+ def process_general_message(message)
246
+ our_nonce = fetch!(message, :your_nonce) # our session nonce echoed back
247
+ peer_key = fetch!(message, :identity_key)
248
+ payload = message[:payload] || message['payload'] || []
249
+ signature = fetch!(message, :signature)
250
+ msg_nonce = fetch!(message, :nonce)
251
+
252
+ # Verify the echoed nonce is one we created
253
+ raise AuthError, "Unable to verify nonce for general message from: #{peer_key}" unless Nonce.verify(our_nonce, @wallet)
254
+
255
+ session = @session_manager.get_session(our_nonce)
256
+ raise AuthError, "Session not found for nonce: #{our_nonce.inspect}" unless session
257
+
258
+ # Verify signature: signed over payload with key_id "msg_nonce session_nonce"
259
+ key_id = key_id_for(msg_nonce, session.session_nonce)
260
+
261
+ @wallet.verify_signature({
262
+ data: payload,
263
+ signature: signature,
264
+ protocol_id: AUTH_PROTOCOL,
265
+ key_id: key_id,
266
+ counterparty: session.peer_identity_key
267
+ })
268
+
269
+ session.last_update = current_time_ms
270
+ @session_manager.update_session(session)
271
+
272
+ { peer_identity_key: session.peer_identity_key, payload: payload }
273
+ rescue BSV::Wallet::InvalidSignatureError
274
+ raise AuthError, "General message signature verification failed from: #{peer_key}"
275
+ end
276
+
277
+ # --- Helpers ---
278
+
279
+ # Builds the key ID string: "prefix suffix".
280
+ # @param prefix [String] first nonce (base64)
281
+ # @param suffix [String] second nonce (base64)
282
+ # @return [String]
283
+ def key_id_for(prefix, suffix)
284
+ "#{prefix} #{suffix}"
285
+ end
286
+
287
+ # Decodes a base64 string to a byte array (Array<Integer>).
288
+ # @param b64 [String]
289
+ # @return [Array<Integer>]
290
+ def b64_decode(b64)
291
+ ::Base64.strict_decode64(b64).bytes
292
+ end
293
+
294
+ # Fetches a value from a message hash, trying both Symbol and String keys.
295
+ # @param message [Hash]
296
+ # @param key [Symbol]
297
+ # @return [Object]
298
+ # @raise [AuthError] if the key is missing or nil
299
+ def fetch!(message, key)
300
+ value = message[key] || message[key.to_s]
301
+ raise AuthError, "Missing required field: #{key}" if value.nil?
302
+ raise AuthError, "Field #{key} must not be empty" if value.respond_to?(:empty?) && value.empty?
303
+
304
+ value
305
+ end
306
+
307
+ def certifiers_present?
308
+ certs = @certificates_to_request[:certifiers] || @certificates_to_request['certifiers']
309
+ certs.is_a?(Array) && !certs.empty?
310
+ end
311
+
312
+ def current_time_ms
313
+ (Time.now.to_f * 1000).to_i
314
+ end
315
+ end
316
+ end
317
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Auth
5
+ # Represents the state of an authentication session with a peer.
6
+ #
7
+ # Sessions are indexed by +session_nonce+ (our nonce for the session),
8
+ # which is the primary key in {SessionManager}. The +peer_identity_key+
9
+ # is a secondary index used to look up sessions by peer.
10
+ #
11
+ # @example Creating a session
12
+ # session = PeerSession.new(session_nonce: nonce)
13
+ # session.peer_identity_key = 'abc123...'
14
+ # session.peer_nonce = 'xyz...'
15
+ class PeerSession
16
+ # @return [String] our nonce for this session (base64) — primary key
17
+ attr_reader :session_nonce
18
+
19
+ # @return [String, nil] the peer's identity key (public key hex)
20
+ attr_accessor :peer_identity_key
21
+
22
+ # @return [String, nil] the nonce we received from the peer (base64)
23
+ attr_accessor :peer_nonce
24
+
25
+ # @return [Boolean] whether mutual authentication is complete
26
+ attr_accessor :is_authenticated
27
+
28
+ # @return [Integer] Unix timestamp (milliseconds) of last update
29
+ attr_accessor :last_update
30
+
31
+ # @return [Boolean] whether certificates are required from this peer
32
+ attr_accessor :certificates_required
33
+
34
+ # @return [Boolean] whether required certificates have been validated
35
+ attr_accessor :certificates_validated
36
+
37
+ # @param session_nonce [String] our nonce for this session (base64)
38
+ def initialize(session_nonce:)
39
+ @session_nonce = session_nonce
40
+ @peer_identity_key = nil
41
+ @peer_nonce = nil
42
+ @is_authenticated = false
43
+ @last_update = current_time_ms
44
+ @certificates_required = false
45
+ @certificates_validated = true
46
+ end
47
+
48
+ # @return [Boolean]
49
+ def authenticated?
50
+ @is_authenticated
51
+ end
52
+
53
+ private
54
+
55
+ def current_time_ms
56
+ (Time.now.to_f * 1000).to_i
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Auth
5
+ # Thread-safe store for {PeerSession} objects.
6
+ #
7
+ # Supports dual-index lookup: by +session_nonce+ (primary) or by
8
+ # +peer_identity_key+ (secondary). Multiple concurrent sessions per
9
+ # peer identity key are supported — the most recently updated session
10
+ # is returned when looking up by identity key.
11
+ #
12
+ # Matches the ts-sdk SessionManager dual-index design.
13
+ class SessionManager
14
+ def initialize
15
+ # session_nonce -> PeerSession
16
+ @by_nonce = {}
17
+ # peer_identity_key -> Set of session_nonces
18
+ @by_identity = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ # Adds a session to the manager.
23
+ #
24
+ # @param session [PeerSession]
25
+ # @raise [ArgumentError] if +session_nonce+ is blank
26
+ def add_session(session)
27
+ raise ArgumentError, 'session_nonce is required' unless session.session_nonce.is_a?(String) && !session.session_nonce.empty?
28
+
29
+ @mutex.synchronize do
30
+ @by_nonce[session.session_nonce] = session
31
+
32
+ if session.peer_identity_key.is_a?(String)
33
+ nonces = @by_identity[session.peer_identity_key] ||= []
34
+ nonces << session.session_nonce unless nonces.include?(session.session_nonce)
35
+ end
36
+ end
37
+ end
38
+
39
+ # Updates an existing session (removes old references and re-adds).
40
+ #
41
+ # @param session [PeerSession]
42
+ def update_session(session)
43
+ @mutex.synchronize do
44
+ remove_session_locked(session)
45
+ add_session_locked(session)
46
+ end
47
+ end
48
+
49
+ # Retrieves a session by +session_nonce+ or +peer_identity_key+.
50
+ #
51
+ # When the identifier is a session nonce, returns that exact session.
52
+ # When the identifier is a peer identity key, returns the most recently
53
+ # updated session for that peer.
54
+ #
55
+ # @param identifier [String]
56
+ # @return [PeerSession, nil]
57
+ def get_session(identifier)
58
+ @mutex.synchronize do
59
+ direct = @by_nonce[identifier]
60
+ return direct if direct
61
+
62
+ nonces = @by_identity[identifier]
63
+ return nil if nonces.nil? || nonces.empty?
64
+
65
+ best = nil
66
+ nonces.each do |nonce|
67
+ s = @by_nonce[nonce]
68
+ next if s.nil?
69
+
70
+ best = s if best.nil? || (s.last_update || 0) > (best.last_update || 0)
71
+ end
72
+ best
73
+ end
74
+ end
75
+
76
+ # Removes a session from the manager.
77
+ #
78
+ # @param session [PeerSession]
79
+ def remove_session(session)
80
+ @mutex.synchronize { remove_session_locked(session) }
81
+ end
82
+
83
+ # @param identifier [String] session nonce or identity key
84
+ # @return [Boolean]
85
+ def session?(identifier)
86
+ @mutex.synchronize do
87
+ return true if @by_nonce.key?(identifier)
88
+
89
+ nonces = @by_identity[identifier]
90
+ !nonces.nil? && !nonces.empty?
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def add_session_locked(session)
97
+ return unless session.session_nonce.is_a?(String) && !session.session_nonce.empty?
98
+
99
+ @by_nonce[session.session_nonce] = session
100
+
101
+ return unless session.peer_identity_key.is_a?(String)
102
+
103
+ nonces = @by_identity[session.peer_identity_key] ||= []
104
+ nonces << session.session_nonce unless nonces.include?(session.session_nonce)
105
+ end
106
+
107
+ def remove_session_locked(session)
108
+ @by_nonce.delete(session.session_nonce)
109
+
110
+ return unless session.peer_identity_key.is_a?(String)
111
+
112
+ nonces = @by_identity[session.peer_identity_key]
113
+ return unless nonces
114
+
115
+ nonces.delete(session.session_nonce)
116
+ @by_identity.delete(session.peer_identity_key) if nonces.empty?
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Auth
5
+ # Duck-typed interface for BRC-31 message transport.
6
+ #
7
+ # Concrete transports (HTTP, WebSocket, in-process, etc.) must implement
8
+ # both methods. Include this module to get the default +NotImplementedError+
9
+ # stubs — override in your implementation class.
10
+ #
11
+ # @example Minimal in-process transport for testing
12
+ # class DirectTransport
13
+ # include BSV::Auth::Transport
14
+ #
15
+ # def initialize(peer)
16
+ # @peer = peer
17
+ # end
18
+ #
19
+ # def send(message)
20
+ # @peer.handle_incoming_message(message)
21
+ # end
22
+ #
23
+ # def on_data(&block)
24
+ # @on_data = block
25
+ # end
26
+ # end
27
+ module Transport
28
+ # Sends a message to the remote peer.
29
+ #
30
+ # @param message [Hash] the serialised auth message
31
+ # @return [void]
32
+ def send(_message)
33
+ raise NotImplementedError, "#{self.class}#send not implemented"
34
+ end
35
+
36
+ # Registers a callback to be invoked when a message arrives.
37
+ #
38
+ # @yieldparam message [Hash] the incoming auth message
39
+ # @return [void]
40
+ def on_data(&_block)
41
+ raise NotImplementedError, "#{self.class}#on_data not implemented"
42
+ end
43
+ end
44
+ end
45
+ end
data/lib/bsv/auth.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Auth
5
+ autoload :AuthError, 'bsv/auth/auth_error'
6
+ autoload :Nonce, 'bsv/auth/nonce'
7
+ autoload :PeerSession, 'bsv/auth/peer_session'
8
+ autoload :SessionManager, 'bsv/auth/session_manager'
9
+ autoload :Transport, 'bsv/auth/transport'
10
+ autoload :Peer, 'bsv/auth/peer'
11
+
12
+ # Protocol version
13
+ AUTH_VERSION = '0.1'
14
+
15
+ # Message type string constants (matching ts-sdk/go-sdk)
16
+ MSG_INITIAL_REQUEST = 'initialRequest'
17
+ MSG_INITIAL_RESPONSE = 'initialResponse'
18
+ MSG_CERT_REQUEST = 'certificateRequest'
19
+ MSG_CERT_RESPONSE = 'certificateResponse'
20
+ MSG_GENERAL = 'general'
21
+ end
22
+ end
@@ -212,7 +212,10 @@ module BSV
212
212
 
213
213
  components[1..].reduce(self) do |key, component|
214
214
  hardened = component.end_with?("'", 'H', 'h')
215
- index = component.to_i
215
+ numeric_part = component.delete("'Hh")
216
+ raise ArgumentError, "invalid path component: '#{component}'" unless numeric_part.match?(/\A\d+\z/)
217
+
218
+ index = numeric_part.to_i
216
219
  index += HARDENED if hardened
217
220
  key.child(index)
218
221
  end