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 +4 -4
- data/lib/bsv/auth/auth_error.rb +8 -0
- data/lib/bsv/auth/nonce.rb +88 -0
- data/lib/bsv/auth/peer.rb +317 -0
- data/lib/bsv/auth/peer_session.rb +60 -0
- data/lib/bsv/auth/session_manager.rb +120 -0
- data/lib/bsv/auth/transport.rb +45 -0
- data/lib/bsv/auth.rb +22 -0
- data/lib/bsv/primitives/extended_key.rb +4 -1
- data/lib/bsv/script/script.rb +59 -0
- data/lib/bsv/transaction/fee_models/live_policy.rb +143 -0
- data/lib/bsv/transaction/fee_models.rb +1 -0
- data/lib/bsv/version.rb +1 -1
- data/lib/bsv/wallet_interface/version.rb +1 -1
- data/lib/bsv/wallet_interface/wallet_client.rb +73 -18
- data/lib/bsv/wallet_interface/wire/reader.rb +238 -0
- data/lib/bsv/wallet_interface/wire/serializer.rb +1993 -0
- data/lib/bsv/wallet_interface/wire/writer.rb +214 -0
- data/lib/bsv/wallet_interface/wire.rb +19 -0
- data/lib/bsv/wallet_interface.rb +1 -0
- data/lib/bsv-sdk.rb +1 -0
- metadata +14 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9924711798c6f50752254b528fb1956bcc39e3cf5cf1b57fdf94a887a3dc9688
|
|
4
|
+
data.tar.gz: d3cd183e1ccdafadbe37b989275cd03d4c505e2432b3ede6d1af5213b7455361
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88f3ed7869e81aec560b47a405e9144d00e3e48cf4b62d5be6bd09e58ac216ebbe8b56ebcff2ac61075c901d56308875b7ff4673d2e066df74f723e6391f3ea5
|
|
7
|
+
data.tar.gz: a88d52282aa5d2a05b906685245b6690c434dbea73fb01ed99be1dc93a4706720c13ee5fb48830a4c3c423c9160db264fe66c910de5995c94047f61e7f56120d
|
|
@@ -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
|
-
|
|
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
|