bsv-sdk 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -0
- 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/ecies.rb +82 -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 +155 -0
- data/lib/bsv/transaction/fee_models/satoshis_per_kilobyte.rb +2 -2
- data/lib/bsv/transaction/fee_models.rb +1 -0
- data/lib/bsv/version.rb +1 -1
- data/lib/bsv/wallet/wallet.rb +2 -2
- data/lib/bsv/wallet_interface/file_store.rb +170 -0
- data/lib/bsv/wallet_interface/version.rb +1 -1
- data/lib/bsv/wallet_interface/wallet_client.rb +75 -19
- 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 +2 -0
- data/lib/bsv-sdk.rb +1 -0
- metadata +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5d32b117209fc6509e5020811725c5b5bb5e776c57fbd0f3cb0afa8cf3cc12ca
|
|
4
|
+
data.tar.gz: 2fa913c8e3fe270b53a1748e2ff4bdb8055945b625a9e3075eb293b78d581fe4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 82e8ca0cf9e266d7c80fe8be42be40450e5a3495db083f90aa098aaf7ff0186ecc4a63b3a1efd9b7447738d857b930f655be291ad098a83ef7a6d0095df0b4d0
|
|
7
|
+
data.tar.gz: 103b3246361a57c3b1161c789c097c147b6c7070781094587f59bdee6caf3141bb5b22934a89e1bfdd4e514b4f45eb2edd1ae218fc92f23e2d520a3a56f72113
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,71 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-04-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
#### Primitives
|
|
13
|
+
|
|
14
|
+
- **Bitcore ECIES** — `ECIES.bitcore_encrypt` / `ECIES.bitcore_decrypt`. AES-256-CBC with random IV, SHA-512(X-coordinate) key derivation. Matches ts-sdk and go-sdk Bitcore variants.
|
|
15
|
+
|
|
16
|
+
#### Transaction
|
|
17
|
+
|
|
18
|
+
- **LivePolicy.default** — one-line convenience for live fee queries via GorillaPool ARC with 5-minute cache and 100 sat/kB fallback.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **Default fee rate**: `SatoshisPerKilobyte` default changed from 50 to 100 sat/kB (matches ts-sdk LivePolicy fallback). `Wallet#fund` default changed from 0.5 to 0.1 sat/byte.
|
|
23
|
+
|
|
24
|
+
### bsv-wallet v0.2.0
|
|
25
|
+
|
|
26
|
+
- **FileStore** — JSON file-backed persistent storage, now the default for `WalletClient`. Data survives process restarts. MemoryStore becomes explicit opt-in for tests.
|
|
27
|
+
- **File permissions** — directory created with 0700, files with 0600. Warns via Logger on startup if permissions are too open.
|
|
28
|
+
- **BRC-31 Auth/Peer** — mutual authentication with nonce-based challenges, ECDSA signatures, and session management.
|
|
29
|
+
- **Wire protocol** — binary ABI serialisation for all 28 BRC-100 methods (call codes 1-28, VarInt encoding).
|
|
30
|
+
- **Certificate issuance** — `acquire_certificate` with `'issuance'` protocol (POST to certifier URL).
|
|
31
|
+
- **OpCat template** — OP_CAT concatenation script template with lock/unlock constructors.
|
|
32
|
+
- **Live fee policy** — `LivePolicy` fee model fetching from ARC `/v1/policy`.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Subject and certifier pinned in certificate issuance response (not overridable by remote certifier)
|
|
37
|
+
- Wire reader negative privileged_reason length crash
|
|
38
|
+
- PUSHDATA1/2/4 bounds check (silent data corruption on truncated scripts)
|
|
39
|
+
- Extended key path validation (reject non-numeric indices)
|
|
40
|
+
|
|
41
|
+
## [0.3.0] - 2026-03-27
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
#### Primitives
|
|
46
|
+
|
|
47
|
+
- **SymmetricKey** — AES-256-GCM encryption/decryption with 32-byte IV (cross-SDK compatible). Construct from random, ECDH, or raw bytes.
|
|
48
|
+
- **BRC-77 SignedMessage** — authenticated message signing and verification using BRC-42 derived keys. Supports targeted (specific verifier) and "anyone" modes.
|
|
49
|
+
- **BRC-78 EncryptedMessage** — end-to-end encrypted messaging using ECDH-derived symmetric keys.
|
|
50
|
+
- **Schnorr ZKP (BRC-94)** — zero-knowledge proof of ECDH shared secret knowledge. `Schnorr.generate_proof` / `Schnorr.verify_proof`.
|
|
51
|
+
- **Shamir's Secret Sharing** — split private keys into threshold shares (`PrivateKey#to_key_shares`) with Lagrange interpolation reconstruction. Backup format with integrity check.
|
|
52
|
+
|
|
53
|
+
#### Script
|
|
54
|
+
|
|
55
|
+
- **PushDrop template** — data carrier with P2PK spending. `Script.pushdrop_lock` / `Script.pushdrop_unlock` with field extraction.
|
|
56
|
+
- **RPuzzle template** — R-puzzle hash-based spending with 6 hash type variants (raw, SHA1, SHA256, RIPEMD160, HASH160, HASH256).
|
|
57
|
+
|
|
58
|
+
#### Transaction
|
|
59
|
+
|
|
60
|
+
- **Benford's law change distribution** — privacy-preserving change output splitting using Benford's first-digit distribution.
|
|
61
|
+
- **ARC X-WaitFor** — `ARC#broadcast` gains `wait_for:` parameter for `X-WaitFor` header (RECEIVED, STORED, ANNOUNCED_TO_NETWORK, SEEN_ON_NETWORK, MINED).
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- Empty plaintext/ciphertext handling on older OpenSSL versions
|
|
66
|
+
- PushDrop detection for minimally-encoded fields
|
|
67
|
+
|
|
68
|
+
### Changed
|
|
69
|
+
|
|
70
|
+
- `Transaction#fee` change distribution uses Benford's law (was equal split)
|
|
71
|
+
- `LineLength` raised to 150
|
|
72
|
+
|
|
8
73
|
## [0.2.1] - 2026-03-07
|
|
9
74
|
|
|
10
75
|
### Fixed
|
|
@@ -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
|