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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +58 -0
  3. data/LICENCE +86 -0
  4. data/README.md +155 -0
  5. data/lib/bsv/attest/configuration.rb +9 -0
  6. data/lib/bsv/attest/response.rb +19 -0
  7. data/lib/bsv/attest/verification_error.rb +7 -0
  8. data/lib/bsv/attest/version.rb +7 -0
  9. data/lib/bsv/attest.rb +71 -0
  10. data/lib/bsv/network/arc.rb +113 -0
  11. data/lib/bsv/network/broadcast_error.rb +15 -0
  12. data/lib/bsv/network/broadcast_response.rb +29 -0
  13. data/lib/bsv/network/chain_provider_error.rb +14 -0
  14. data/lib/bsv/network/utxo.rb +28 -0
  15. data/lib/bsv/network/whats_on_chain.rb +82 -0
  16. data/lib/bsv/network.rb +12 -0
  17. data/lib/bsv/primitives/base58.rb +117 -0
  18. data/lib/bsv/primitives/bsm.rb +131 -0
  19. data/lib/bsv/primitives/curve.rb +115 -0
  20. data/lib/bsv/primitives/digest.rb +99 -0
  21. data/lib/bsv/primitives/ecdsa.rb +224 -0
  22. data/lib/bsv/primitives/ecies.rb +128 -0
  23. data/lib/bsv/primitives/extended_key.rb +315 -0
  24. data/lib/bsv/primitives/mnemonic/wordlist.rb +270 -0
  25. data/lib/bsv/primitives/mnemonic.rb +192 -0
  26. data/lib/bsv/primitives/private_key.rb +139 -0
  27. data/lib/bsv/primitives/public_key.rb +118 -0
  28. data/lib/bsv/primitives/schnorr.rb +108 -0
  29. data/lib/bsv/primitives/signature.rb +136 -0
  30. data/lib/bsv/primitives.rb +23 -0
  31. data/lib/bsv/script/builder.rb +73 -0
  32. data/lib/bsv/script/chunk.rb +77 -0
  33. data/lib/bsv/script/interpreter/error.rb +54 -0
  34. data/lib/bsv/script/interpreter/interpreter.rb +281 -0
  35. data/lib/bsv/script/interpreter/operations/arithmetic.rb +243 -0
  36. data/lib/bsv/script/interpreter/operations/bitwise.rb +68 -0
  37. data/lib/bsv/script/interpreter/operations/crypto.rb +209 -0
  38. data/lib/bsv/script/interpreter/operations/data_push.rb +34 -0
  39. data/lib/bsv/script/interpreter/operations/flow_control.rb +94 -0
  40. data/lib/bsv/script/interpreter/operations/splice.rb +89 -0
  41. data/lib/bsv/script/interpreter/operations/stack_ops.rb +112 -0
  42. data/lib/bsv/script/interpreter/script_number.rb +218 -0
  43. data/lib/bsv/script/interpreter/stack.rb +203 -0
  44. data/lib/bsv/script/opcodes.rb +165 -0
  45. data/lib/bsv/script/script.rb +424 -0
  46. data/lib/bsv/script.rb +20 -0
  47. data/lib/bsv/transaction/beef.rb +323 -0
  48. data/lib/bsv/transaction/merkle_path.rb +250 -0
  49. data/lib/bsv/transaction/p2pkh.rb +44 -0
  50. data/lib/bsv/transaction/sighash.rb +48 -0
  51. data/lib/bsv/transaction/transaction.rb +380 -0
  52. data/lib/bsv/transaction/transaction_input.rb +109 -0
  53. data/lib/bsv/transaction/transaction_output.rb +51 -0
  54. data/lib/bsv/transaction/unlocking_script_template.rb +36 -0
  55. data/lib/bsv/transaction/var_int.rb +50 -0
  56. data/lib/bsv/transaction.rb +21 -0
  57. data/lib/bsv/version.rb +5 -0
  58. data/lib/bsv/wallet/insufficient_funds_error.rb +15 -0
  59. data/lib/bsv/wallet/wallet.rb +119 -0
  60. data/lib/bsv/wallet.rb +8 -0
  61. data/lib/bsv-attest.rb +4 -0
  62. data/lib/bsv-sdk.rb +11 -0
  63. 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