Class: BSV::Primitives::ExtendedKey

Inherits:
Object
  • Object
show all
Defined in:
lib/bsv/primitives/extended_key.rb

Overview

BIP-32 hierarchical deterministic (HD) extended key.

Supports both private and public extended keys, serialised as Base58Check xprv/xpub strings. Provides child key derivation (normal and hardened), path-based derivation (+m/44’/0’/0’+), and neutering (private → public).

Examples:

Derive keys from a seed

seed = SecureRandom.random_bytes(32)
master = BSV::Primitives::ExtendedKey.from_seed(seed)
child  = master.derive_path("m/44'/0'/0'/0/0")
child.public_key.address #=> "1..."

Parse an xpub string

xpub = BSV::Primitives::ExtendedKey.from_string('xpub6...')
xpub.public? #=> true

Constant Summary collapse

HARDENED =

Offset added to child indices for hardened derivation.

0x80000000
VERSIONS =

Version bytes for extended key serialisation (BIP-32).

{
  mainnet_private: "\x04\x88\xAD\xE4".b,
  mainnet_public: "\x04\x88\xB2\x1E".b,
  testnet_private: "\x04\x35\x83\x94".b,
  testnet_public: "\x04\x35\x87\xCF".b
}.freeze
PRIVATE_VERSIONS =

Private extended key version bytes.

[VERSIONS[:mainnet_private], VERSIONS[:testnet_private]].freeze
PUBLIC_VERSIONS =

Public extended key version bytes.

[VERSIONS[:mainnet_public], VERSIONS[:testnet_public]].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(key:, chain_code:, version:, depth: 0, parent_fingerprint: "\x00\x00\x00\x00".b, child_number: 0) ⇒ ExtendedKey

Returns a new instance of ExtendedKey.

Parameters:

  • key (String)

    raw key bytes

  • chain_code (String)

    32-byte chain code

  • version (String)

    4-byte version prefix

  • depth (Integer) (defaults to: 0)

    derivation depth

  • parent_fingerprint (String) (defaults to: "\x00\x00\x00\x00".b)

    4-byte parent fingerprint

  • child_number (Integer) (defaults to: 0)

    child index



65
66
67
68
69
70
71
72
# File 'lib/bsv/primitives/extended_key.rb', line 65

def initialize(key:, chain_code:, version:, depth: 0, parent_fingerprint: "\x00\x00\x00\x00".b, child_number: 0)
  @key = key
  @chain_code = chain_code
  @depth = depth
  @parent_fingerprint = parent_fingerprint
  @child_number = child_number
  @version = version
end

Instance Attribute Details

#chain_codeString (readonly)

Returns 32-byte chain code for child derivation.

Returns:

  • (String)

    32-byte chain code for child derivation



45
46
47
# File 'lib/bsv/primitives/extended_key.rb', line 45

def chain_code
  @chain_code
end

#child_numberInteger (readonly)

Returns child number (index used to derive this key).

Returns:

  • (Integer)

    child number (index used to derive this key)



54
55
56
# File 'lib/bsv/primitives/extended_key.rb', line 54

def child_number
  @child_number
end

#depthInteger (readonly)

Returns depth in the derivation tree (0 = master).

Returns:

  • (Integer)

    depth in the derivation tree (0 = master)



48
49
50
# File 'lib/bsv/primitives/extended_key.rb', line 48

def depth
  @depth
end

#keyString (readonly)

Returns raw key bytes (32-byte private or 33-byte compressed public).

Returns:

  • (String)

    raw key bytes (32-byte private or 33-byte compressed public)



42
43
44
# File 'lib/bsv/primitives/extended_key.rb', line 42

def key
  @key
end

#parent_fingerprintString (readonly)

Returns 4-byte fingerprint of the parent key.

Returns:

  • (String)

    4-byte fingerprint of the parent key



51
52
53
# File 'lib/bsv/primitives/extended_key.rb', line 51

def parent_fingerprint
  @parent_fingerprint
end

#versionString (readonly)

Returns 4-byte version prefix.

Returns:

  • (String)

    4-byte version prefix



57
58
59
# File 'lib/bsv/primitives/extended_key.rb', line 57

def version
  @version
end

Class Method Details

.from_seed(seed, network: :mainnet) ⇒ ExtendedKey

Derive a master extended key from a binary seed.

Uses HMAC-SHA-512 with key “Bitcoin seed” per BIP-32.

Parameters:

  • seed (String)

    16-64 byte seed (typically from Mnemonic#to_seed)

  • network (Symbol) (defaults to: :mainnet)

    :mainnet or :testnet

Returns:

Raises:

  • (ArgumentError)

    if the seed length is invalid or derives an invalid key



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/bsv/primitives/extended_key.rb', line 82

def self.from_seed(seed, network: :mainnet)
  seed = seed.b
  raise ArgumentError, 'seed must be between 16 and 64 bytes' unless seed.length.between?(16, 64)

  hmac = Digest.hmac_sha512('Bitcoin seed', seed)
  il = hmac[0, 32]
  ir = hmac[32, 32]

  il_bn = OpenSSL::BN.new(il, 2)
  raise ArgumentError, 'invalid seed: derived key is zero or >= curve order' if il_bn.zero? || il_bn >= Curve::N

  new(
    key: il,
    chain_code: ir,
    version: VERSIONS[:"#{network}_private"]
  )
end

.from_string(base58) ⇒ ExtendedKey

Parse an extended key from a Base58Check-encoded string (xprv/xpub).

Parameters:

  • base58 (String)

    Base58Check-encoded extended key

Returns:

Raises:

  • (ArgumentError)

    if the encoding, length, or version/key mismatch is invalid



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/bsv/primitives/extended_key.rb', line 105

def self.from_string(base58)
  data = Base58.check_decode(base58)
  raise ArgumentError, "invalid extended key length: #{data.length}" unless data.length == 78

  version = data[0, 4]
  depth = data[4].unpack1('C')
  parent_fingerprint = data[5, 4]
  child_number = data[9, 4].unpack1('N')
  chain_code = data[13, 32]
  key_data = data[45, 33]

  if key_data[0] == "\x00".b
    raise ArgumentError, 'private key data with public version bytes' unless PRIVATE_VERSIONS.include?(version)

    key = key_data[1, 32]
  elsif ["\x02".b, "\x03".b].include?(key_data[0])
    raise ArgumentError, 'public key data with private version bytes' unless PUBLIC_VERSIONS.include?(version)

    key = key_data
  else
    raise ArgumentError, "invalid key data prefix: 0x#{key_data[0].unpack1('H*')}"
  end

  new(
    key: key,
    chain_code: chain_code,
    version: version,
    depth: depth,
    parent_fingerprint: parent_fingerprint,
    child_number: child_number
  )
end

Instance Method Details

#child(index) ⇒ ExtendedKey

Derive a child key at the given index.

Indices below HARDENED produce normal (public-derivable) children. Indices >= HARDENED produce hardened children (private key required).

Parameters:

  • index (Integer)

    the child index (use HARDENED n+ for hardened)

Returns:

Raises:

  • (ArgumentError)

    if deriving hardened from a public key, or at max depth



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/bsv/primitives/extended_key.rb', line 160

def child(index)
  raise ArgumentError, 'cannot derive child at depth 255' if @depth >= 255

  if index >= HARDENED
    raise ArgumentError, 'cannot derive hardened child from public key' if public?

    data = "\x00".b + padded_key + [index].pack('N')
  else
    data = compressed_pubkey_bytes + [index].pack('N')
  end

  hmac = Digest.hmac_sha512(@chain_code, data)
  il = hmac[0, 32]
  ir = hmac[32, 32]

  il_bn = OpenSSL::BN.new(il, 2)
  raise ArgumentError, 'invalid child: IL >= curve order' if il_bn >= Curve::N

  fp = fingerprint

  child_key_bytes = if private?
                      child_key_bn = il_bn.mod_add(OpenSSL::BN.new(@key, 2), Curve::N)
                      raise ArgumentError, 'invalid child: derived key is zero' if child_key_bn.zero?

                      bn_to_32bytes(child_key_bn)
                    else
                      parent_point = Curve.point_from_bytes(@key)
                      il_point = Curve.multiply_generator_ct(il_bn)
                      child_point = Curve.add_points(parent_point, il_point)
                      raise ArgumentError, 'invalid child: derived point is at infinity' if child_point.infinity?

                      child_point.to_octet_string(:compressed)
                    end

  self.class.new(
    key: child_key_bytes,
    chain_code: ir,
    version: @version,
    depth: @depth + 1,
    parent_fingerprint: fp,
    child_number: index
  )
end

#derive_path(path) ⇒ ExtendedKey

Derive a child key from a BIP-32 path string.

Parameters:

  • path (String)

    derivation path (e.g. “m/44’/0’/0’/0/0”)

Returns:

Raises:

  • (ArgumentError)

    if the path does not start with ‘m’



209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/bsv/primitives/extended_key.rb', line 209

def derive_path(path)
  components = path.strip.split('/')
  raise ArgumentError, "path must start with 'm'" unless components.first == 'm'

  components[1..].reduce(self) do |key, component|
    hardened = component.end_with?("'", 'H', 'h')
    numeric_part = component.delete("'Hh")
    raise ArgumentError, "invalid path component: '#{component}'" unless numeric_part.match?(/\A\d+\z/)

    index = numeric_part.to_i
    index += HARDENED if hardened
    key.child(index)
  end
end

#fingerprintString

The 4-byte fingerprint of this key (first 4 bytes of identifier).

Returns:

  • (String)

    4-byte fingerprint



283
284
285
# File 'lib/bsv/primitives/extended_key.rb', line 283

def fingerprint
  identifier[0, 4]
end

#identifierString

The 20-byte Hash160 identifier for this key.

Returns:

  • (String)

    20-byte Hash160 of the compressed public key



290
291
292
# File 'lib/bsv/primitives/extended_key.rb', line 290

def identifier
  Digest.hash160(compressed_pubkey_bytes)
end

#neuterExtendedKey

Convert a private extended key to its public counterpart.

Returns:

Raises:

  • (ArgumentError)

    if already a public key



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/bsv/primitives/extended_key.rb', line 228

def neuter
  raise ArgumentError, 'already a public key' if public?

  pub_version = if @version == VERSIONS[:mainnet_private]
                  VERSIONS[:mainnet_public]
                else
                  VERSIONS[:testnet_public]
                end

  self.class.new(
    key: compressed_pubkey_bytes,
    chain_code: @chain_code,
    version: pub_version,
    depth: @depth,
    parent_fingerprint: @parent_fingerprint,
    child_number: @child_number
  )
end

#private?Boolean

Whether this is a private extended key.

Returns:

  • (Boolean)


141
142
143
# File 'lib/bsv/primitives/extended_key.rb', line 141

def private?
  PRIVATE_VERSIONS.include?(@version)
end

#private_keyPrivateKey

Extract the PrivateKey from a private extended key.

Returns:

Raises:

  • (ArgumentError)

    if this is a public extended key



267
268
269
270
271
# File 'lib/bsv/primitives/extended_key.rb', line 267

def private_key
  raise ArgumentError, 'not a private extended key' unless private?

  PrivateKey.from_bytes(padded_key)
end

#public?Boolean

Whether this is a public extended key.

Returns:

  • (Boolean)


148
149
150
# File 'lib/bsv/primitives/extended_key.rb', line 148

def public?
  PUBLIC_VERSIONS.include?(@version)
end

#public_keyPublicKey

Extract the PublicKey from this extended key.

Returns:



276
277
278
# File 'lib/bsv/primitives/extended_key.rb', line 276

def public_key
  PublicKey.from_bytes(compressed_pubkey_bytes)
end

#to_sString

Serialise as a Base58Check-encoded string (xprv or xpub).

Returns:

  • (String)

    the Base58Check-encoded extended key



250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/bsv/primitives/extended_key.rb', line 250

def to_s
  key_data = private? ? "\x00".b + padded_key : @key

  payload = @version +
            [@depth].pack('C') +
            @parent_fingerprint +
            [@child_number].pack('N') +
            @chain_code +
            key_data

  Base58.check_encode(payload)
end