Class: BSV::Primitives::PrivateKey

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

Overview

A secp256k1 private key for signing transactions and deriving public keys.

Can be created from random entropy, raw bytes, hex, or WIF (Wallet Import Format). Produces deterministic ECDSA signatures via ECDSA.

Examples:

Generate a new random key

key = BSV::Primitives::PrivateKey.generate
key.to_wif #=> "5J..."

Import from WIF

key = BSV::Primitives::PrivateKey.from_wif('5HueCGU8rMjxEX...')
key.public_key.address #=> "1GAeh..."

Constant Summary collapse

MAINNET_PREFIX =

WIF version prefix for mainnet private keys.

"\x80".b
TESTNET_PREFIX =

WIF version prefix for testnet private keys.

"\xef".b

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(bn) ⇒ PrivateKey

Returns a new instance of PrivateKey.

Parameters:

  • bn (OpenSSL::BN)

    the private key scalar (must be 1 < bn < N)

Raises:

  • (ArgumentError)

    if bn is not an OpenSSL::BN or is out of range



32
33
34
35
36
37
# File 'lib/bsv/primitives/private_key.rb', line 32

def initialize(bn)
  raise ArgumentError, 'private key must be an OpenSSL::BN' unless bn.is_a?(OpenSSL::BN)
  raise ArgumentError, 'private key out of range' if bn <= OpenSSL::BN.new('0') || bn >= Curve::N

  @bn = bn
end

Instance Attribute Details

#bnOpenSSL::BN (readonly)

Returns the private key as a big number.

Returns:

  • (OpenSSL::BN)

    the private key as a big number



28
29
30
# File 'lib/bsv/primitives/private_key.rb', line 28

def bn
  @bn
end

Class Method Details

.from_backup_shares(shares) ⇒ PrivateKey

Reconstruct a private key from backup-format share strings.

Parameters:

  • shares (Array<String>)

    backup-format share strings

Returns:



275
276
277
# File 'lib/bsv/primitives/private_key.rb', line 275

def self.from_backup_shares(shares)
  from_key_shares(KeyShares.from_backup_format(shares))
end

.from_bytes(bytes) ⇒ PrivateKey

Create a private key from raw 32-byte big-endian encoding.

Parameters:

  • bytes (String)

    32-byte binary string

Returns:



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

def self.from_bytes(bytes)
  new(OpenSSL::BN.new(bytes, 2))
end

.from_hex(hex) ⇒ PrivateKey

Create a private key from a hex string.

Parameters:

  • hex (String)

    64-character hex-encoded private key

Returns:



62
63
64
# File 'lib/bsv/primitives/private_key.rb', line 62

def self.from_hex(hex)
  new(OpenSSL::BN.new(hex, 16))
end

.from_key_shares(key_shares) ⇒ PrivateKey

Reconstruct a private key from a KeyShares object.

Evaluates the Lagrange polynomial at x=0 to recover the secret, then checks the integrity hash against the reconstructed public key.

Parameters:

  • key_shares (KeyShares)

    the shares to combine

Returns:

Raises:

  • (ArgumentError)

    if there are too few shares, duplicates, or integrity fails



249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/bsv/primitives/private_key.rb', line 249

def self.from_key_shares(key_shares)
  points    = key_shares.points
  threshold = key_shares.threshold
  integrity = key_shares.integrity

  raise ArgumentError, 'threshold must be at least 2' if threshold < 2
  raise ArgumentError, "at least #{threshold} shares are required" if points.length < threshold

  # Guard against duplicate x-coordinates
  xs = points.first(threshold).map { |p| p.x.to_s }
  raise ArgumentError, 'duplicate share detected; each share must be unique' if xs.length != xs.uniq.length

  poly    = Polynomial.new(points.first(threshold), threshold)
  secret  = poly.value_at(OpenSSL::BN.new('0'))
  key     = new(secret)

  actual_integrity = key.public_key.hash160[0, 4].unpack1('H*')
  raise ArgumentError, 'integrity hash mismatch — shares may be corrupt or belong to a different key' unless actual_integrity == integrity

  key
end

.from_wif(wif_string) ⇒ PrivateKey

Create a private key from Wallet Import Format (WIF).

Supports both compressed and uncompressed WIF encodings, and both mainnet and testnet prefixes.

Parameters:

  • wif_string (String)

    Base58Check-encoded WIF string

Returns:

Raises:

  • (ArgumentError)

    if the WIF prefix, length, or compression flag is invalid



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/bsv/primitives/private_key.rb', line 74

def self.from_wif(wif_string)
  data = Base58.check_decode(wif_string)
  prefix = data[0]
  raise ArgumentError, "unknown WIF network prefix: 0x#{prefix.unpack1('H*')}" unless [MAINNET_PREFIX, TESTNET_PREFIX].include?(prefix)

  case data.length
  when 33
    # Uncompressed: prefix (1) + key (32)
    from_bytes(data[1, 32])
  when 34
    # Compressed: prefix (1) + key (32) + 0x01 (1)
    raise ArgumentError, 'invalid compression flag' unless data[33] == "\x01".b

    from_bytes(data[1, 32])
  else
    raise ArgumentError, "invalid WIF length: #{data.length}"
  end
end

.generatePrivateKey

Generate a new random private key using secure random bytes.

Returns:

  • (PrivateKey)

    a cryptographically random private key



42
43
44
45
46
47
48
# File 'lib/bsv/primitives/private_key.rb', line 42

def self.generate
  loop do
    bytes = SecureRandom.random_bytes(32)
    bn = OpenSSL::BN.new(bytes, 2)
    return new(bn) if bn > OpenSSL::BN.new('0') && bn < Curve::N
  end
end

Instance Method Details

#derive_child(public_key, invoice_number) ⇒ PrivateKey

Derive a child private key using BRC-42 key derivation.

Computes HMAC-SHA256(key: ECDH_shared_secret, msg: invoice_number) and adds it to this private key’s scalar mod n. The corresponding public key can be derived without the private key using BSV::Primitives::PublicKey#derive_child.

Parameters:

  • public_key (PublicKey)

    the counterparty’s public key

  • invoice_number (String)

    the invoice number (UTF-8)

Returns:



165
166
167
168
169
170
# File 'lib/bsv/primitives/private_key.rb', line 165

def derive_child(public_key, invoice_number)
  shared = derive_shared_secret(public_key)
  hmac = Digest.hmac_sha256(shared.compressed, invoice_number.encode('UTF-8'))
  hmac_bn = OpenSSL::BN.new(hmac.unpack1('H*'), 16)
  PrivateKey.new(@bn.mod_add(hmac_bn, Curve::N))
end

#derive_shared_secret(public_key) ⇒ PublicKey

Derive an ECDH shared secret with another party’s public key.

Computes the shared point by multiplying the given public key by this private key’s scalar. The result is commutative:

alice_priv.derive_shared_secret(bob_pub) ==
  bob_priv.derive_shared_secret(alice_pub)

Uses constant-time scalar multiplication to protect the private key scalar from timing side-channels.

This is the foundational primitive for BRC-42 key derivation, BRC-77/78 messaging, and ECIES encryption.

Parameters:

  • public_key (PublicKey)

    the other party’s public key

Returns:

  • (PublicKey)

    the shared secret as a public key (curve point)



150
151
152
153
# File 'lib/bsv/primitives/private_key.rb', line 150

def derive_shared_secret(public_key)
  shared_point = Curve.multiply_point_ct(public_key.point, @bn)
  PublicKey.new(shared_point)
end

#public_keyPublicKey

Derive the corresponding public key.

Uses constant-time scalar multiplication to protect the private key scalar from timing side-channels during derivation.

Returns:

  • (PublicKey)

    the public key for this private key



131
132
133
# File 'lib/bsv/primitives/private_key.rb', line 131

def public_key
  @public_key ||= PublicKey.new(Curve.multiply_generator_ct(@bn))
end

#sign(hash) ⇒ Signature

Sign a 32-byte hash using deterministic ECDSA (RFC 6979).

Parameters:

  • hash (String)

    32-byte message digest to sign

Returns:

  • (Signature)

    the DER-encodable signature



176
177
178
# File 'lib/bsv/primitives/private_key.rb', line 176

def sign(hash)
  ECDSA.sign(hash, @bn)
end

#to_backup_shares(threshold, total_shares) ⇒ Array<String>

Serialise this key as Shamir backup share strings.

Convenience wrapper around #to_key_shares and KeyShares#to_backup_format.

Parameters:

  • threshold (Integer)

    minimum shares needed to reconstruct

  • total_shares (Integer)

    total shares to generate

Returns:

  • (Array<String>)

    backup-format share strings



237
238
239
# File 'lib/bsv/primitives/private_key.rb', line 237

def to_backup_shares(threshold, total_shares)
  to_key_shares(threshold, total_shares).to_backup_format
end

#to_bytesString

Serialise the private key as 32-byte big-endian binary.

Returns:

  • (String)

    32-byte binary string (zero-padded)



96
97
98
99
100
# File 'lib/bsv/primitives/private_key.rb', line 96

def to_bytes
  raw = @bn.to_s(2)
  # Pad to 32 bytes
  raw.length < 32 ? ("\x00".b * (32 - raw.length)) + raw : raw
end

#to_hexString

Serialise the private key as a 64-character hex string.

Returns:

  • (String)

    hex-encoded private key



105
106
107
# File 'lib/bsv/primitives/private_key.rb', line 105

def to_hex
  to_bytes.unpack1('H*')
end

#to_key_shares(threshold, total_shares) ⇒ KeyShares

Split this private key into Shamir’s Secret Sharing shares.

Generates total_shares evaluation points on a random polynomial whose y-intercept encodes this key. Any threshold of them suffice to reconstruct the key. X-coordinates are derived via HMAC-SHA-512 over a 64-byte random seed, ensuring uniqueness even under partial RNG failure.

Parameters:

  • threshold (Integer)

    minimum shares needed to reconstruct (>= 2)

  • total_shares (Integer)

    total shares to generate (>= threshold)

Returns:

Raises:

  • (ArgumentError)

    if parameters are out of range



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/bsv/primitives/private_key.rb', line 191

def to_key_shares(threshold, total_shares)
  raise ArgumentError, 'threshold must be an integer'    unless threshold.is_a?(Integer)
  raise ArgumentError, 'total_shares must be an integer' unless total_shares.is_a?(Integer)
  raise ArgumentError, 'threshold must be at least 2'    if threshold < 2
  raise ArgumentError, 'total_shares must be at least 2' if total_shares < 2
  raise ArgumentError, 'threshold must be <= total_shares' if threshold > total_shares

  poly = Polynomial.from_private_key(self, threshold: threshold)

  seed          = SecureRandom.random_bytes(64)
  used_x        = {}
  points        = []

  total_shares.times do |i|
    x = nil
    attempts = 0
    loop do
      counter_bytes = [i, attempts].pack('N*') + SecureRandom.random_bytes(32)
      h             = Digest.hmac_sha512(seed, counter_bytes)
      candidate     = OpenSSL::BN.new(h.unpack1('H*'), 16) % PointInFiniteField::P

      attempts += 1
      raise ArgumentError, 'failed to generate unique x-coordinate after 5 attempts' if attempts > 5

      next if candidate.zero? || used_x.key?(candidate.to_s)

      x = candidate
      break
    end

    used_x[x.to_s] = true
    y = poly.value_at(x)
    points << PointInFiniteField.new(x, y)
  end

  integrity = public_key.hash160[0, 4].unpack1('H*')
  KeyShares.new(points, threshold, integrity)
end

#to_wif(network: :mainnet) ⇒ String

Serialise the private key in Wallet Import Format (WIF).

Always produces a compressed WIF (the 0x01 compression flag is appended). BSV exclusively uses compressed public keys; uncompressed WIF export is not supported (“construct only what’s valid”).

The from_wif parser continues to accept both compressed and uncompressed WIF for import compatibility with legacy wallets.

Parameters:

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

    :mainnet or :testnet

Returns:

  • (String)

    Base58Check-encoded WIF string (compressed format)



120
121
122
123
# File 'lib/bsv/primitives/private_key.rb', line 120

def to_wif(network: :mainnet)
  prefix = network == :mainnet ? MAINNET_PREFIX : TESTNET_PREFIX
  Base58.check_encode(prefix + to_bytes + "\x01".b)
end