bsv-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +58 -0
- data/LICENCE +86 -0
- data/README.md +155 -0
- data/lib/bsv/attest/configuration.rb +9 -0
- data/lib/bsv/attest/response.rb +19 -0
- data/lib/bsv/attest/verification_error.rb +7 -0
- data/lib/bsv/attest/version.rb +7 -0
- data/lib/bsv/attest.rb +71 -0
- data/lib/bsv/network/arc.rb +113 -0
- data/lib/bsv/network/broadcast_error.rb +15 -0
- data/lib/bsv/network/broadcast_response.rb +29 -0
- data/lib/bsv/network/chain_provider_error.rb +14 -0
- data/lib/bsv/network/utxo.rb +28 -0
- data/lib/bsv/network/whats_on_chain.rb +82 -0
- data/lib/bsv/network.rb +12 -0
- data/lib/bsv/primitives/base58.rb +117 -0
- data/lib/bsv/primitives/bsm.rb +131 -0
- data/lib/bsv/primitives/curve.rb +115 -0
- data/lib/bsv/primitives/digest.rb +99 -0
- data/lib/bsv/primitives/ecdsa.rb +224 -0
- data/lib/bsv/primitives/ecies.rb +128 -0
- data/lib/bsv/primitives/extended_key.rb +315 -0
- data/lib/bsv/primitives/mnemonic/wordlist.rb +270 -0
- data/lib/bsv/primitives/mnemonic.rb +192 -0
- data/lib/bsv/primitives/private_key.rb +139 -0
- data/lib/bsv/primitives/public_key.rb +118 -0
- data/lib/bsv/primitives/schnorr.rb +108 -0
- data/lib/bsv/primitives/signature.rb +136 -0
- data/lib/bsv/primitives.rb +23 -0
- data/lib/bsv/script/builder.rb +73 -0
- data/lib/bsv/script/chunk.rb +77 -0
- data/lib/bsv/script/interpreter/error.rb +54 -0
- data/lib/bsv/script/interpreter/interpreter.rb +281 -0
- data/lib/bsv/script/interpreter/operations/arithmetic.rb +243 -0
- data/lib/bsv/script/interpreter/operations/bitwise.rb +68 -0
- data/lib/bsv/script/interpreter/operations/crypto.rb +209 -0
- data/lib/bsv/script/interpreter/operations/data_push.rb +34 -0
- data/lib/bsv/script/interpreter/operations/flow_control.rb +94 -0
- data/lib/bsv/script/interpreter/operations/splice.rb +89 -0
- data/lib/bsv/script/interpreter/operations/stack_ops.rb +112 -0
- data/lib/bsv/script/interpreter/script_number.rb +218 -0
- data/lib/bsv/script/interpreter/stack.rb +203 -0
- data/lib/bsv/script/opcodes.rb +165 -0
- data/lib/bsv/script/script.rb +424 -0
- data/lib/bsv/script.rb +20 -0
- data/lib/bsv/transaction/beef.rb +323 -0
- data/lib/bsv/transaction/merkle_path.rb +250 -0
- data/lib/bsv/transaction/p2pkh.rb +44 -0
- data/lib/bsv/transaction/sighash.rb +48 -0
- data/lib/bsv/transaction/transaction.rb +380 -0
- data/lib/bsv/transaction/transaction_input.rb +109 -0
- data/lib/bsv/transaction/transaction_output.rb +51 -0
- data/lib/bsv/transaction/unlocking_script_template.rb +36 -0
- data/lib/bsv/transaction/var_int.rb +50 -0
- data/lib/bsv/transaction.rb +21 -0
- data/lib/bsv/version.rb +5 -0
- data/lib/bsv/wallet/insufficient_funds_error.rb +15 -0
- data/lib/bsv/wallet/wallet.rb +119 -0
- data/lib/bsv/wallet.rb +8 -0
- data/lib/bsv-attest.rb +4 -0
- data/lib/bsv-sdk.rb +11 -0
- metadata +104 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module BSV
|
|
6
|
+
module Primitives
|
|
7
|
+
# Deterministic ECDSA signing and verification on secp256k1.
|
|
8
|
+
#
|
|
9
|
+
# Implements RFC 6979 deterministic nonce generation to produce
|
|
10
|
+
# signatures that are fully reproducible from the same (key, hash)
|
|
11
|
+
# pair. All signatures are normalised to low-S form (BIP-62 rule 5).
|
|
12
|
+
#
|
|
13
|
+
# Typically used indirectly via {PrivateKey#sign} and {PublicKey#verify}
|
|
14
|
+
# rather than calling this module directly.
|
|
15
|
+
module ECDSA
|
|
16
|
+
# Byte length of a secp256k1 scalar (256 bits).
|
|
17
|
+
BYTE_LEN = 32 # secp256k1 order is 256 bits = 32 bytes
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Sign a 32-byte message hash with a private key.
|
|
22
|
+
#
|
|
23
|
+
# @param hash [String] 32-byte message digest
|
|
24
|
+
# @param private_key_bn [OpenSSL::BN] the private key scalar
|
|
25
|
+
# @return [Signature] a low-S normalised signature
|
|
26
|
+
def sign(hash, private_key_bn)
|
|
27
|
+
sig, _recovery_id = sign_raw(hash, private_key_bn)
|
|
28
|
+
sig
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Sign a hash and return both the signature and recovery ID.
|
|
32
|
+
#
|
|
33
|
+
# The recovery ID (0-3) allows the public key to be recovered
|
|
34
|
+
# from the signature without knowing it in advance, as used by
|
|
35
|
+
# Bitcoin Signed Messages (BSM) and compact signature formats.
|
|
36
|
+
#
|
|
37
|
+
# @param hash [String] 32-byte message digest
|
|
38
|
+
# @param private_key_bn [OpenSSL::BN] the private key scalar
|
|
39
|
+
# @return [Array(Signature, Integer)] the signature and recovery ID
|
|
40
|
+
def sign_recoverable(hash, private_key_bn)
|
|
41
|
+
sign_raw(hash, private_key_bn)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Recover a public key from a signature and recovery ID.
|
|
45
|
+
#
|
|
46
|
+
# Given a message hash, signature, and the recovery ID produced
|
|
47
|
+
# during signing, reconstructs the public key that created the
|
|
48
|
+
# signature.
|
|
49
|
+
#
|
|
50
|
+
# @param hash [String] 32-byte message digest that was signed
|
|
51
|
+
# @param signature [Signature] the ECDSA signature
|
|
52
|
+
# @param recovery_id [Integer] recovery ID (0-3)
|
|
53
|
+
# @return [PublicKey] the recovered public key
|
|
54
|
+
# @raise [ArgumentError] if the recovered point is at infinity
|
|
55
|
+
def recover_public_key(hash, signature, recovery_id)
|
|
56
|
+
r = signature.r
|
|
57
|
+
s = signature.s
|
|
58
|
+
n = Curve::N
|
|
59
|
+
|
|
60
|
+
# Reconstruct R.x (may include overflow when recovery_id >= 2)
|
|
61
|
+
x = recovery_id >= 2 ? r + n : r
|
|
62
|
+
|
|
63
|
+
# Decompress R from x-coordinate and y-parity
|
|
64
|
+
prefix = (recovery_id & 1).odd? ? "\x03".b : "\x02".b
|
|
65
|
+
x_bytes = x.to_s(2)
|
|
66
|
+
x_bytes = ("\x00".b * (32 - x_bytes.length)) + x_bytes if x_bytes.length < 32
|
|
67
|
+
r_point = Curve.point_from_bytes(prefix + x_bytes)
|
|
68
|
+
|
|
69
|
+
# Q = r^(-1) * (s*R - e*G)
|
|
70
|
+
r_inv = r.mod_inverse(n)
|
|
71
|
+
e = OpenSSL::BN.new(hash, 2)
|
|
72
|
+
u1 = ((n - e) * r_inv) % n
|
|
73
|
+
u2 = (s * r_inv) % n
|
|
74
|
+
|
|
75
|
+
p1 = Curve.multiply_generator(u1)
|
|
76
|
+
p2 = Curve.multiply_point(r_point, u2)
|
|
77
|
+
q = Curve.add_points(p1, p2)
|
|
78
|
+
|
|
79
|
+
raise ArgumentError, 'recovered point is at infinity' if q.infinity?
|
|
80
|
+
|
|
81
|
+
PublicKey.new(q)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Verify an ECDSA signature against a message hash and public key.
|
|
85
|
+
#
|
|
86
|
+
# @param hash [String] 32-byte message digest
|
|
87
|
+
# @param signature [Signature] the signature to verify
|
|
88
|
+
# @param public_key_point [OpenSSL::PKey::EC::Point] the signer's public key point
|
|
89
|
+
# @return [Boolean] +true+ if the signature is valid
|
|
90
|
+
def verify(hash, signature, public_key_point)
|
|
91
|
+
r = signature.r
|
|
92
|
+
s = signature.s
|
|
93
|
+
n = Curve::N
|
|
94
|
+
|
|
95
|
+
return false if r <= OpenSSL::BN.new('0') || r >= n
|
|
96
|
+
return false if s <= OpenSSL::BN.new('0') || s >= n
|
|
97
|
+
|
|
98
|
+
e = OpenSSL::BN.new(hash, 2)
|
|
99
|
+
s_inv = s.mod_inverse(n)
|
|
100
|
+
|
|
101
|
+
u1 = (e * s_inv) % n
|
|
102
|
+
u2 = (r * s_inv) % n
|
|
103
|
+
|
|
104
|
+
# R' = u1*G + u2*Q
|
|
105
|
+
point1 = Curve.multiply_generator(u1)
|
|
106
|
+
point2 = Curve.multiply_point(public_key_point, u2)
|
|
107
|
+
result_point = Curve.add_points(point1, point2)
|
|
108
|
+
|
|
109
|
+
return false if result_point.infinity?
|
|
110
|
+
|
|
111
|
+
x = Curve.point_x(result_point) % n
|
|
112
|
+
x == r
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class << self
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def sign_raw(hash, private_key_bn)
|
|
119
|
+
k = nonce_rfc6979(private_key_bn, hash)
|
|
120
|
+
k_inv = k.mod_inverse(Curve::N)
|
|
121
|
+
|
|
122
|
+
r_point = Curve.multiply_generator(k)
|
|
123
|
+
r = Curve.point_x(r_point) % Curve::N
|
|
124
|
+
raise 'calculated R is zero' if r.zero?
|
|
125
|
+
|
|
126
|
+
e = OpenSSL::BN.new(hash, 2)
|
|
127
|
+
s = (k_inv * ((e + (private_key_bn * r)) % Curve::N)) % Curve::N
|
|
128
|
+
raise 'calculated S is zero' if s.zero?
|
|
129
|
+
|
|
130
|
+
# Recovery ID: bit 0 = R.y parity, bit 1 = R.x overflow (>= N)
|
|
131
|
+
r_y_odd = r_point.to_octet_string(:compressed).getbyte(0) == 0x03
|
|
132
|
+
r_overflow = Curve.point_x(r_point) >= Curve::N
|
|
133
|
+
recovery_id = (r_y_odd ? 1 : 0) + (r_overflow ? 2 : 0)
|
|
134
|
+
|
|
135
|
+
sig = Signature.new(r, s)
|
|
136
|
+
unless sig.low_s?
|
|
137
|
+
sig = sig.to_low_s
|
|
138
|
+
recovery_id ^= 1 # Flipping s negates R.y, toggling parity
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
[sig, recovery_id]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# RFC 6979 Section 3.2 — deterministic k generation for secp256k1/SHA-256
|
|
145
|
+
def nonce_rfc6979(privkey_bn, hash)
|
|
146
|
+
q = Curve::N
|
|
147
|
+
qlen = q.num_bits # 256 for secp256k1
|
|
148
|
+
rolen = (qlen + 7) >> 3 # 32
|
|
149
|
+
|
|
150
|
+
# a. Process input: private key as fixed-width octets
|
|
151
|
+
x_bytes = int2octets(privkey_bn, rolen)
|
|
152
|
+
# b. Process hash
|
|
153
|
+
h_bytes = bits2octets(hash, rolen)
|
|
154
|
+
|
|
155
|
+
# bx = int2octets(x) || bits2octets(hash)
|
|
156
|
+
bx = x_bytes + h_bytes
|
|
157
|
+
|
|
158
|
+
# Step B: V = 0x01 * hlen (32 bytes of 0x01)
|
|
159
|
+
v = "\x01".b * 32
|
|
160
|
+
|
|
161
|
+
# Step C: K = 0x00 * hlen (32 bytes of 0x00)
|
|
162
|
+
k = "\x00".b * 32
|
|
163
|
+
|
|
164
|
+
# Step D: K = HMAC(K, V || 0x00 || bx)
|
|
165
|
+
k = Digest.hmac_sha256(k, v + "\x00".b + bx)
|
|
166
|
+
|
|
167
|
+
# Step E: V = HMAC(K, V)
|
|
168
|
+
v = Digest.hmac_sha256(k, v)
|
|
169
|
+
|
|
170
|
+
# Step F: K = HMAC(K, V || 0x01 || bx)
|
|
171
|
+
k = Digest.hmac_sha256(k, v + "\x01".b + bx)
|
|
172
|
+
|
|
173
|
+
# Step G: V = HMAC(K, V)
|
|
174
|
+
v = Digest.hmac_sha256(k, v)
|
|
175
|
+
|
|
176
|
+
# Step H: Generate and test candidates
|
|
177
|
+
loop do
|
|
178
|
+
# H1/H2: Generate qlen bits
|
|
179
|
+
t = ''.b
|
|
180
|
+
while t.length * 8 < qlen
|
|
181
|
+
v = Digest.hmac_sha256(k, v)
|
|
182
|
+
t += v
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# H3: Convert to integer and test
|
|
186
|
+
secret = bits2int(t, qlen)
|
|
187
|
+
return secret if secret >= OpenSSL::BN.new('1') && secret < q
|
|
188
|
+
|
|
189
|
+
# Not valid — update K, V and retry
|
|
190
|
+
k = Digest.hmac_sha256(k, v + "\x00".b)
|
|
191
|
+
v = Digest.hmac_sha256(k, v)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Convert integer to fixed-width big-endian octets
|
|
196
|
+
def int2octets(bn, rolen)
|
|
197
|
+
bytes = bn.to_s(2)
|
|
198
|
+
if bytes.length > rolen
|
|
199
|
+
bytes[-rolen, rolen]
|
|
200
|
+
elsif bytes.length < rolen
|
|
201
|
+
("\x00".b * (rolen - bytes.length)) + bytes
|
|
202
|
+
else
|
|
203
|
+
bytes
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Convert hash bits to integer, reduce mod q, then to octets
|
|
208
|
+
def bits2octets(hash_bytes, rolen)
|
|
209
|
+
z1 = bits2int(hash_bytes, Curve::N.num_bits)
|
|
210
|
+
z2 = z1 % Curve::N
|
|
211
|
+
int2octets(z2, rolen)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Convert bit string to integer, right-shifting if longer than qlen
|
|
215
|
+
def bits2int(bytes, qlen)
|
|
216
|
+
blen = bytes.length * 8
|
|
217
|
+
v = OpenSSL::BN.new(bytes, 2)
|
|
218
|
+
v >>= (blen - qlen) if blen > qlen
|
|
219
|
+
v
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module BSV
|
|
6
|
+
module Primitives
|
|
7
|
+
# Elliptic Curve Integrated Encryption Scheme (ECIES) using the
|
|
8
|
+
# Electrum/BIE1 protocol.
|
|
9
|
+
#
|
|
10
|
+
# Provides authenticated encryption using an ephemeral ECDH shared secret.
|
|
11
|
+
# The sender generates a random key pair, derives a shared secret with the
|
|
12
|
+
# recipient's public key, then encrypts with AES-128-CBC and authenticates
|
|
13
|
+
# with HMAC-SHA-256 (encrypt-then-MAC).
|
|
14
|
+
#
|
|
15
|
+
# @example Encrypt and decrypt a message
|
|
16
|
+
# alice = BSV::Primitives::PrivateKey.generate
|
|
17
|
+
# bob = BSV::Primitives::PrivateKey.generate
|
|
18
|
+
#
|
|
19
|
+
# ciphertext = BSV::Primitives::ECIES.encrypt('hello', bob.public_key)
|
|
20
|
+
# plaintext = BSV::Primitives::ECIES.decrypt(ciphertext, bob)
|
|
21
|
+
module ECIES
|
|
22
|
+
# BIE1 magic bytes identifying the Electrum ECIES format.
|
|
23
|
+
MAGIC = 'BIE1'.b.freeze
|
|
24
|
+
|
|
25
|
+
# Raised when HMAC verification or AES decryption fails.
|
|
26
|
+
class DecryptionError < StandardError; end
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Encrypt a message for a recipient's public key.
|
|
31
|
+
#
|
|
32
|
+
# @param message [String] the plaintext message
|
|
33
|
+
# @param public_key [PublicKey] the recipient's public key
|
|
34
|
+
# @param private_key [PrivateKey, nil] optional ephemeral key (random if omitted)
|
|
35
|
+
# @return [String] encrypted payload: BIE1 magic + ephemeral pubkey + ciphertext + HMAC
|
|
36
|
+
def encrypt(message, public_key, private_key: nil)
|
|
37
|
+
message = message.b if message.encoding != Encoding::ASCII_8BIT
|
|
38
|
+
|
|
39
|
+
ephemeral = private_key || PrivateKey.generate
|
|
40
|
+
ephemeral_pub = ephemeral.public_key
|
|
41
|
+
|
|
42
|
+
iv, key_e, key_m = derive_keys(public_key.point, ephemeral.bn)
|
|
43
|
+
|
|
44
|
+
cipher = OpenSSL::Cipher.new('aes-128-cbc')
|
|
45
|
+
cipher.encrypt
|
|
46
|
+
cipher.key = key_e
|
|
47
|
+
cipher.iv = iv
|
|
48
|
+
ciphertext = message.empty? ? cipher.final : cipher.update(message) + cipher.final
|
|
49
|
+
|
|
50
|
+
payload = MAGIC + ephemeral_pub.compressed + ciphertext
|
|
51
|
+
mac = Digest.hmac_sha256(key_m, payload)
|
|
52
|
+
|
|
53
|
+
payload + mac
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Decrypt an ECIES-encrypted message with a private key.
|
|
57
|
+
#
|
|
58
|
+
# Verifies the HMAC before attempting decryption (encrypt-then-MAC).
|
|
59
|
+
#
|
|
60
|
+
# @param data [String] the encrypted payload (BIE1 format)
|
|
61
|
+
# @param private_key [PrivateKey] the recipient's private key
|
|
62
|
+
# @return [String] the decrypted plaintext
|
|
63
|
+
# @raise [ArgumentError] if the data is too short or has invalid magic bytes
|
|
64
|
+
# @raise [DecryptionError] if HMAC verification or AES decryption fails
|
|
65
|
+
def decrypt(data, private_key)
|
|
66
|
+
data = data.b if data.encoding != Encoding::ASCII_8BIT
|
|
67
|
+
|
|
68
|
+
raise ArgumentError, 'data too short' if data.bytesize < 85
|
|
69
|
+
|
|
70
|
+
magic = data[0, 4]
|
|
71
|
+
raise ArgumentError, 'invalid magic: expected BIE1' unless magic == MAGIC
|
|
72
|
+
|
|
73
|
+
ephemeral_pub_bytes = data[4, 33]
|
|
74
|
+
mac = data[-32, 32]
|
|
75
|
+
ciphertext = data[37...-32]
|
|
76
|
+
|
|
77
|
+
ephemeral_pub = PublicKey.from_bytes(ephemeral_pub_bytes)
|
|
78
|
+
|
|
79
|
+
iv, key_e, key_m = derive_keys(ephemeral_pub.point, private_key.bn)
|
|
80
|
+
|
|
81
|
+
# Verify HMAC before decryption (encrypt-then-MAC)
|
|
82
|
+
payload = data[0...-32]
|
|
83
|
+
expected_mac = Digest.hmac_sha256(key_m, payload)
|
|
84
|
+
|
|
85
|
+
raise DecryptionError, 'HMAC verification failed' unless secure_compare(mac, expected_mac)
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
cipher = OpenSSL::Cipher.new('aes-128-cbc')
|
|
89
|
+
cipher.decrypt
|
|
90
|
+
cipher.key = key_e
|
|
91
|
+
cipher.iv = iv
|
|
92
|
+
cipher.update(ciphertext) + cipher.final
|
|
93
|
+
rescue OpenSSL::Cipher::CipherError => e
|
|
94
|
+
raise DecryptionError, "decryption failed: #{e.message}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class << self
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def secure_compare(mac, expected)
|
|
102
|
+
return false unless mac.bytesize == expected.bytesize
|
|
103
|
+
|
|
104
|
+
if OpenSSL.respond_to?(:fixed_length_secure_compare)
|
|
105
|
+
OpenSSL.fixed_length_secure_compare(mac, expected)
|
|
106
|
+
else
|
|
107
|
+
# Constant-time comparison for Ruby < 3.2
|
|
108
|
+
result = 0
|
|
109
|
+
mac.bytes.zip(expected.bytes) { |x, y| result |= x ^ y }
|
|
110
|
+
result.zero?
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def derive_keys(point, scalar_bn)
|
|
115
|
+
shared_point = Curve.multiply_point(point, scalar_bn)
|
|
116
|
+
ecdh_key = shared_point.to_octet_string(:compressed)
|
|
117
|
+
derived = Digest.sha512(ecdh_key)
|
|
118
|
+
|
|
119
|
+
iv = derived[0, 16]
|
|
120
|
+
key_e = derived[16, 16]
|
|
121
|
+
key_m = derived[32, 32]
|
|
122
|
+
|
|
123
|
+
[iv, key_e, key_m]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module BSV
|
|
6
|
+
module Primitives
|
|
7
|
+
# BIP-32 hierarchical deterministic (HD) extended key.
|
|
8
|
+
#
|
|
9
|
+
# Supports both private and public extended keys, serialised as
|
|
10
|
+
# Base58Check xprv/xpub strings. Provides child key derivation
|
|
11
|
+
# (normal and hardened), path-based derivation (+m/44'/0'/0'+),
|
|
12
|
+
# and neutering (private → public).
|
|
13
|
+
#
|
|
14
|
+
# @example Derive keys from a seed
|
|
15
|
+
# seed = SecureRandom.random_bytes(32)
|
|
16
|
+
# master = BSV::Primitives::ExtendedKey.from_seed(seed)
|
|
17
|
+
# child = master.derive_path("m/44'/0'/0'/0/0")
|
|
18
|
+
# child.public_key.address #=> "1..."
|
|
19
|
+
#
|
|
20
|
+
# @example Parse an xpub string
|
|
21
|
+
# xpub = BSV::Primitives::ExtendedKey.from_string('xpub6...')
|
|
22
|
+
# xpub.public? #=> true
|
|
23
|
+
class ExtendedKey
|
|
24
|
+
# Offset added to child indices for hardened derivation.
|
|
25
|
+
HARDENED = 0x80000000
|
|
26
|
+
|
|
27
|
+
# Version bytes for extended key serialisation (BIP-32).
|
|
28
|
+
VERSIONS = {
|
|
29
|
+
mainnet_private: "\x04\x88\xAD\xE4".b,
|
|
30
|
+
mainnet_public: "\x04\x88\xB2\x1E".b,
|
|
31
|
+
testnet_private: "\x04\x35\x83\x94".b,
|
|
32
|
+
testnet_public: "\x04\x35\x87\xCF".b
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# Private extended key version bytes.
|
|
36
|
+
PRIVATE_VERSIONS = [VERSIONS[:mainnet_private], VERSIONS[:testnet_private]].freeze
|
|
37
|
+
|
|
38
|
+
# Public extended key version bytes.
|
|
39
|
+
PUBLIC_VERSIONS = [VERSIONS[:mainnet_public], VERSIONS[:testnet_public]].freeze
|
|
40
|
+
|
|
41
|
+
# @return [String] raw key bytes (32-byte private or 33-byte compressed public)
|
|
42
|
+
attr_reader :key
|
|
43
|
+
|
|
44
|
+
# @return [String] 32-byte chain code for child derivation
|
|
45
|
+
attr_reader :chain_code
|
|
46
|
+
|
|
47
|
+
# @return [Integer] depth in the derivation tree (0 = master)
|
|
48
|
+
attr_reader :depth
|
|
49
|
+
|
|
50
|
+
# @return [String] 4-byte fingerprint of the parent key
|
|
51
|
+
attr_reader :parent_fingerprint
|
|
52
|
+
|
|
53
|
+
# @return [Integer] child number (index used to derive this key)
|
|
54
|
+
attr_reader :child_number
|
|
55
|
+
|
|
56
|
+
# @return [String] 4-byte version prefix
|
|
57
|
+
attr_reader :version
|
|
58
|
+
|
|
59
|
+
# @param key [String] raw key bytes
|
|
60
|
+
# @param chain_code [String] 32-byte chain code
|
|
61
|
+
# @param version [String] 4-byte version prefix
|
|
62
|
+
# @param depth [Integer] derivation depth
|
|
63
|
+
# @param parent_fingerprint [String] 4-byte parent fingerprint
|
|
64
|
+
# @param child_number [Integer] child index
|
|
65
|
+
def initialize(key:, chain_code:, version:, depth: 0, parent_fingerprint: "\x00\x00\x00\x00".b,
|
|
66
|
+
child_number: 0)
|
|
67
|
+
@key = key
|
|
68
|
+
@chain_code = chain_code
|
|
69
|
+
@depth = depth
|
|
70
|
+
@parent_fingerprint = parent_fingerprint
|
|
71
|
+
@child_number = child_number
|
|
72
|
+
@version = version
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Derive a master extended key from a binary seed.
|
|
76
|
+
#
|
|
77
|
+
# Uses HMAC-SHA-512 with key +"Bitcoin seed"+ per BIP-32.
|
|
78
|
+
#
|
|
79
|
+
# @param seed [String] 16-64 byte seed (typically from {Mnemonic#to_seed})
|
|
80
|
+
# @param network [Symbol] +:mainnet+ or +:testnet+
|
|
81
|
+
# @return [ExtendedKey] the master private extended key
|
|
82
|
+
# @raise [ArgumentError] if the seed length is invalid or derives an invalid key
|
|
83
|
+
def self.from_seed(seed, network: :mainnet)
|
|
84
|
+
seed = seed.b
|
|
85
|
+
raise ArgumentError, 'seed must be between 16 and 64 bytes' unless seed.length.between?(16, 64)
|
|
86
|
+
|
|
87
|
+
hmac = Digest.hmac_sha512('Bitcoin seed', seed)
|
|
88
|
+
il = hmac[0, 32]
|
|
89
|
+
ir = hmac[32, 32]
|
|
90
|
+
|
|
91
|
+
il_bn = OpenSSL::BN.new(il, 2)
|
|
92
|
+
raise ArgumentError, 'invalid seed: derived key is zero or >= curve order' if il_bn.zero? || il_bn >= Curve::N
|
|
93
|
+
|
|
94
|
+
new(
|
|
95
|
+
key: il,
|
|
96
|
+
chain_code: ir,
|
|
97
|
+
version: VERSIONS[:"#{network}_private"]
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Parse an extended key from a Base58Check-encoded string (xprv/xpub).
|
|
102
|
+
#
|
|
103
|
+
# @param base58 [String] Base58Check-encoded extended key
|
|
104
|
+
# @return [ExtendedKey]
|
|
105
|
+
# @raise [ArgumentError] if the encoding, length, or version/key mismatch is invalid
|
|
106
|
+
def self.from_string(base58)
|
|
107
|
+
data = Base58.check_decode(base58)
|
|
108
|
+
raise ArgumentError, "invalid extended key length: #{data.length}" unless data.length == 78
|
|
109
|
+
|
|
110
|
+
version = data[0, 4]
|
|
111
|
+
depth = data[4].unpack1('C')
|
|
112
|
+
parent_fingerprint = data[5, 4]
|
|
113
|
+
child_number = data[9, 4].unpack1('N')
|
|
114
|
+
chain_code = data[13, 32]
|
|
115
|
+
key_data = data[45, 33]
|
|
116
|
+
|
|
117
|
+
if key_data[0] == "\x00".b
|
|
118
|
+
raise ArgumentError, 'private key data with public version bytes' unless PRIVATE_VERSIONS.include?(version)
|
|
119
|
+
|
|
120
|
+
key = key_data[1, 32]
|
|
121
|
+
elsif ["\x02".b, "\x03".b].include?(key_data[0])
|
|
122
|
+
raise ArgumentError, 'public key data with private version bytes' unless PUBLIC_VERSIONS.include?(version)
|
|
123
|
+
|
|
124
|
+
key = key_data
|
|
125
|
+
else
|
|
126
|
+
raise ArgumentError, "invalid key data prefix: 0x#{key_data[0].unpack1('H*')}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
new(
|
|
130
|
+
key: key,
|
|
131
|
+
chain_code: chain_code,
|
|
132
|
+
version: version,
|
|
133
|
+
depth: depth,
|
|
134
|
+
parent_fingerprint: parent_fingerprint,
|
|
135
|
+
child_number: child_number
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Whether this is a private extended key.
|
|
140
|
+
#
|
|
141
|
+
# @return [Boolean]
|
|
142
|
+
def private?
|
|
143
|
+
PRIVATE_VERSIONS.include?(@version)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Whether this is a public extended key.
|
|
147
|
+
#
|
|
148
|
+
# @return [Boolean]
|
|
149
|
+
def public?
|
|
150
|
+
PUBLIC_VERSIONS.include?(@version)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Derive a child key at the given index.
|
|
154
|
+
#
|
|
155
|
+
# Indices below {HARDENED} produce normal (public-derivable) children.
|
|
156
|
+
# Indices >= {HARDENED} produce hardened children (private key required).
|
|
157
|
+
#
|
|
158
|
+
# @param index [Integer] the child index (use +HARDENED + n+ for hardened)
|
|
159
|
+
# @return [ExtendedKey] the derived child key
|
|
160
|
+
# @raise [ArgumentError] if deriving hardened from a public key, or at max depth
|
|
161
|
+
def child(index)
|
|
162
|
+
raise ArgumentError, 'cannot derive child at depth 255' if @depth >= 255
|
|
163
|
+
|
|
164
|
+
if index >= HARDENED
|
|
165
|
+
raise ArgumentError, 'cannot derive hardened child from public key' if public?
|
|
166
|
+
|
|
167
|
+
data = "\x00".b + padded_key + [index].pack('N')
|
|
168
|
+
else
|
|
169
|
+
data = compressed_pubkey_bytes + [index].pack('N')
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
hmac = Digest.hmac_sha512(@chain_code, data)
|
|
173
|
+
il = hmac[0, 32]
|
|
174
|
+
ir = hmac[32, 32]
|
|
175
|
+
|
|
176
|
+
il_bn = OpenSSL::BN.new(il, 2)
|
|
177
|
+
raise ArgumentError, 'invalid child: IL >= curve order' if il_bn >= Curve::N
|
|
178
|
+
|
|
179
|
+
fp = fingerprint
|
|
180
|
+
|
|
181
|
+
child_key_bytes = if private?
|
|
182
|
+
child_key_bn = il_bn.mod_add(OpenSSL::BN.new(@key, 2), Curve::N)
|
|
183
|
+
raise ArgumentError, 'invalid child: derived key is zero' if child_key_bn.zero?
|
|
184
|
+
|
|
185
|
+
bn_to_32bytes(child_key_bn)
|
|
186
|
+
else
|
|
187
|
+
parent_point = Curve.point_from_bytes(@key)
|
|
188
|
+
il_point = Curve.multiply_generator(il_bn)
|
|
189
|
+
child_point = Curve.add_points(parent_point, il_point)
|
|
190
|
+
raise ArgumentError, 'invalid child: derived point is at infinity' if child_point.infinity?
|
|
191
|
+
|
|
192
|
+
child_point.to_octet_string(:compressed)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
self.class.new(
|
|
196
|
+
key: child_key_bytes,
|
|
197
|
+
chain_code: ir,
|
|
198
|
+
version: @version,
|
|
199
|
+
depth: @depth + 1,
|
|
200
|
+
parent_fingerprint: fp,
|
|
201
|
+
child_number: index
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Derive a child key from a BIP-32 path string.
|
|
206
|
+
#
|
|
207
|
+
# @param path [String] derivation path (e.g. +"m/44'/0'/0'/0/0"+)
|
|
208
|
+
# @return [ExtendedKey] the derived key
|
|
209
|
+
# @raise [ArgumentError] if the path does not start with +'m'+
|
|
210
|
+
def derive_path(path)
|
|
211
|
+
components = path.strip.split('/')
|
|
212
|
+
raise ArgumentError, "path must start with 'm'" unless components.first == 'm'
|
|
213
|
+
|
|
214
|
+
components[1..].reduce(self) do |key, component|
|
|
215
|
+
hardened = component.end_with?("'", 'H', 'h')
|
|
216
|
+
index = component.to_i
|
|
217
|
+
index += HARDENED if hardened
|
|
218
|
+
key.child(index)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Convert a private extended key to its public counterpart.
|
|
223
|
+
#
|
|
224
|
+
# @return [ExtendedKey] the public extended key (xpub)
|
|
225
|
+
# @raise [ArgumentError] if already a public key
|
|
226
|
+
def neuter
|
|
227
|
+
raise ArgumentError, 'already a public key' if public?
|
|
228
|
+
|
|
229
|
+
pub_version = if @version == VERSIONS[:mainnet_private]
|
|
230
|
+
VERSIONS[:mainnet_public]
|
|
231
|
+
else
|
|
232
|
+
VERSIONS[:testnet_public]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
self.class.new(
|
|
236
|
+
key: compressed_pubkey_bytes,
|
|
237
|
+
chain_code: @chain_code,
|
|
238
|
+
version: pub_version,
|
|
239
|
+
depth: @depth,
|
|
240
|
+
parent_fingerprint: @parent_fingerprint,
|
|
241
|
+
child_number: @child_number
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Serialise as a Base58Check-encoded string (xprv or xpub).
|
|
246
|
+
#
|
|
247
|
+
# @return [String] the Base58Check-encoded extended key
|
|
248
|
+
def to_s
|
|
249
|
+
key_data = private? ? "\x00".b + padded_key : @key
|
|
250
|
+
|
|
251
|
+
payload = @version +
|
|
252
|
+
[@depth].pack('C') +
|
|
253
|
+
@parent_fingerprint +
|
|
254
|
+
[@child_number].pack('N') +
|
|
255
|
+
@chain_code +
|
|
256
|
+
key_data
|
|
257
|
+
|
|
258
|
+
Base58.check_encode(payload)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Extract the {PrivateKey} from a private extended key.
|
|
262
|
+
#
|
|
263
|
+
# @return [PrivateKey]
|
|
264
|
+
# @raise [ArgumentError] if this is a public extended key
|
|
265
|
+
def private_key
|
|
266
|
+
raise ArgumentError, 'not a private extended key' unless private?
|
|
267
|
+
|
|
268
|
+
PrivateKey.from_bytes(padded_key)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Extract the {PublicKey} from this extended key.
|
|
272
|
+
#
|
|
273
|
+
# @return [PublicKey]
|
|
274
|
+
def public_key
|
|
275
|
+
PublicKey.from_bytes(compressed_pubkey_bytes)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# The 4-byte fingerprint of this key (first 4 bytes of identifier).
|
|
279
|
+
#
|
|
280
|
+
# @return [String] 4-byte fingerprint
|
|
281
|
+
def fingerprint
|
|
282
|
+
identifier[0, 4]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The 20-byte Hash160 identifier for this key.
|
|
286
|
+
#
|
|
287
|
+
# @return [String] 20-byte Hash160 of the compressed public key
|
|
288
|
+
def identifier
|
|
289
|
+
Digest.hash160(compressed_pubkey_bytes)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
private
|
|
293
|
+
|
|
294
|
+
def compressed_pubkey_bytes
|
|
295
|
+
if private?
|
|
296
|
+
bn = OpenSSL::BN.new(@key, 2)
|
|
297
|
+
point = Curve.multiply_generator(bn)
|
|
298
|
+
point.to_octet_string(:compressed)
|
|
299
|
+
else
|
|
300
|
+
@key
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def padded_key
|
|
305
|
+
raw = @key.b
|
|
306
|
+
raw.length < 32 ? ("\x00".b * (32 - raw.length)) + raw : raw
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def bn_to_32bytes(bn)
|
|
310
|
+
raw = bn.to_s(2)
|
|
311
|
+
raw.length < 32 ? ("\x00".b * (32 - raw.length)) + raw : raw
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|