bsv-sdk 0.2.1 → 0.3.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/lib/bsv/network/broadcast_response.rb +1 -2
  3. data/lib/bsv/primitives/bsm.rb +2 -6
  4. data/lib/bsv/primitives/curve.rb +1 -2
  5. data/lib/bsv/primitives/encrypted_message.rb +100 -0
  6. data/lib/bsv/primitives/extended_key.rb +1 -2
  7. data/lib/bsv/primitives/key_shares.rb +83 -0
  8. data/lib/bsv/primitives/mnemonic.rb +1 -3
  9. data/lib/bsv/primitives/point_in_finite_field.rb +72 -0
  10. data/lib/bsv/primitives/polynomial.rb +95 -0
  11. data/lib/bsv/primitives/private_key.rb +100 -3
  12. data/lib/bsv/primitives/signed_message.rb +104 -0
  13. data/lib/bsv/primitives/symmetric_key.rb +128 -0
  14. data/lib/bsv/primitives.rb +18 -12
  15. data/lib/bsv/script/interpreter/interpreter.rb +1 -3
  16. data/lib/bsv/script/interpreter/operations/bitwise.rb +1 -3
  17. data/lib/bsv/script/interpreter/operations/crypto.rb +3 -9
  18. data/lib/bsv/script/interpreter/operations/flow_control.rb +2 -6
  19. data/lib/bsv/script/interpreter/operations/splice.rb +1 -3
  20. data/lib/bsv/script/interpreter/script_number.rb +2 -7
  21. data/lib/bsv/script/script.rb +252 -1
  22. data/lib/bsv/transaction/beef.rb +1 -4
  23. data/lib/bsv/transaction/transaction.rb +123 -45
  24. data/lib/bsv/transaction/transaction_input.rb +1 -2
  25. data/lib/bsv/transaction/transaction_output.rb +1 -2
  26. data/lib/bsv/transaction/var_int.rb +4 -16
  27. data/lib/bsv/transaction.rb +14 -14
  28. data/lib/bsv/version.rb +1 -1
  29. data/lib/bsv/wallet_interface/errors/invalid_hmac_error.rb +11 -0
  30. data/lib/bsv/wallet_interface/errors/invalid_parameter_error.rb +14 -0
  31. data/lib/bsv/wallet_interface/errors/invalid_signature_error.rb +11 -0
  32. data/lib/bsv/wallet_interface/errors/unsupported_action_error.rb +11 -0
  33. data/lib/bsv/wallet_interface/errors/wallet_error.rb +14 -0
  34. data/lib/bsv/wallet_interface/interface.rb +384 -0
  35. data/lib/bsv/wallet_interface/key_deriver.rb +142 -0
  36. data/lib/bsv/wallet_interface/memory_store.rb +115 -0
  37. data/lib/bsv/wallet_interface/proto_wallet.rb +361 -0
  38. data/lib/bsv/wallet_interface/storage_adapter.rb +51 -0
  39. data/lib/bsv/wallet_interface/validators.rb +126 -0
  40. data/lib/bsv/wallet_interface/version.rb +7 -0
  41. data/lib/bsv/wallet_interface/wallet_client.rb +486 -0
  42. data/lib/bsv/wallet_interface.rb +25 -0
  43. data/lib/bsv-wallet.rb +4 -0
  44. metadata +24 -3
  45. /data/{LICENCE → LICENSE} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8f24ed29fd0a8beb572b3737c3bbfaba5bf939c5fe20d485306e5e4374587c1
4
- data.tar.gz: 53746a1cdad1370718892805b76c763fbe58a73ef057bd65a1eaa4b1b120c7bd
3
+ metadata.gz: 61435802c338981fb3f6fd8d5881aac9aa83b1cdb261f02dc2a9e2ffa1eb8fa7
4
+ data.tar.gz: '0159ac16ce996bc45503c6342e0ec763d82285968d9b614c209b4c38fc8f2980'
5
5
  SHA512:
6
- metadata.gz: 93590d116e66151f51af5167bb405b2d91cd2997f685fad0d684cd07f5231aa1387d57d71852e5464f3f222d1b471534d22ab6633c75d188f9080c1718f834a0
7
- data.tar.gz: b26e9a26837af3ce9426311050c0e2fbbdaac9ce4d04a7987c123ef189ec6528e779c69bd7b77605e25df70c1510327a71b7843151f50eb710b2b63a788eeae2
6
+ metadata.gz: 17c9ee9dee1b8454aff19753196dbbc9a04463bdca7da03c6c0769d23b9603efb9ef9f4d6fd57b4dc426a99077d5780c9bb5e978e10e6675809301fc9fea1178
7
+ data.tar.gz: 1a1bbbee8c570c28c8c608320e1db986527d1e847e5c2cb1943c2b35ec10f3fd6de983bea3eb0026b4216caecee2771c246a0ba3eebc497f2bce88325a58002c
@@ -3,8 +3,7 @@
3
3
  module BSV
4
4
  module Network
5
5
  class BroadcastResponse
6
- attr_reader :txid, :tx_status, :message, :extra_info,
7
- :block_hash, :block_height, :timestamp, :competing_txs
6
+ attr_reader :txid, :tx_status, :message, :extra_info, :block_hash, :block_height, :timestamp, :competing_txs
8
7
 
9
8
  def initialize(attrs = {})
10
9
  @txid = attrs[:txid]
@@ -97,10 +97,7 @@ module BSV
97
97
 
98
98
  def decode_compact(signature)
99
99
  compact = signature.unpack1('m0')
100
- unless compact.bytesize == 65
101
- raise ArgumentError,
102
- "invalid signature length: #{compact.bytesize} (expected 65)"
103
- end
100
+ raise ArgumentError, "invalid signature length: #{compact.bytesize} (expected 65)" unless compact.bytesize == 65
104
101
 
105
102
  compact
106
103
  rescue ArgumentError => e
@@ -112,8 +109,7 @@ module BSV
112
109
  def validate_flag!(flag)
113
110
  return if flag.between?(27, 34)
114
111
 
115
- raise ArgumentError,
116
- "flag byte #{flag} out of range (expected 27-34)"
112
+ raise ArgumentError, "flag byte #{flag} out of range (expected 27-34)"
117
113
  end
118
114
 
119
115
  def encode_varint(len)
@@ -90,8 +90,7 @@ module BSV
90
90
  OpenSSL::ASN1::Integer.new(1),
91
91
  OpenSSL::ASN1::OctetString.new(private_bytes),
92
92
  OpenSSL::ASN1::ObjectId.new('secp256k1', 0, :EXPLICIT),
93
- OpenSSL::ASN1::BitString.new(pub_point.to_octet_string(:compressed), 1,
94
- :EXPLICIT)
93
+ OpenSSL::ASN1::BitString.new(pub_point.to_octet_string(:compressed), 1, :EXPLICIT)
95
94
  ])
96
95
  OpenSSL::PKey::EC.new(asn1.to_der)
97
96
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module BSV
6
+ module Primitives
7
+ # BRC-78 encrypted messages.
8
+ #
9
+ # Provides confidential authenticated messaging using BRC-42 derived keys
10
+ # and AES-256-GCM symmetric encryption. Both parties derive the same
11
+ # symmetric key from their respective BRC-42 child keys via ECDH.
12
+ #
13
+ # @example Encrypt and decrypt
14
+ # ct = EncryptedMessage.encrypt(message, sender_priv, recipient_pub)
15
+ # EncryptedMessage.decrypt(ct, recipient_priv) #=> message
16
+ #
17
+ # @see https://github.com/bitcoin-sv/BRCs/blob/master/peer-to-peer/0078.md
18
+ module EncryptedMessage
19
+ # Protocol version bytes: "BB\x10\x33"
20
+ VERSION = "\x42\x42\x10\x33".b.freeze
21
+
22
+ # Minimum message size: VERSION(4) + sender(33) + recipient(33) + key_id(32) = 102
23
+ HEADER_SIZE = 102
24
+
25
+ module_function
26
+
27
+ # Encrypt a message using the BRC-78 protocol.
28
+ #
29
+ # @param message [String] the message to encrypt
30
+ # @param sender [PrivateKey] the sender's private key
31
+ # @param recipient [PublicKey] the recipient's public key
32
+ # @return [String] binary encrypted message (header + AES-GCM payload)
33
+ def encrypt(message, sender, recipient)
34
+ key_id = SecureRandom.random_bytes(32)
35
+ invoice = "2-message encryption-#{[key_id].pack('m0')}"
36
+
37
+ sym_key = derive_symmetric_key(sender, recipient, invoice)
38
+ encrypted = sym_key.encrypt(message)
39
+
40
+ VERSION +
41
+ sender.public_key.compressed +
42
+ recipient.compressed +
43
+ key_id +
44
+ encrypted
45
+ end
46
+
47
+ # Decrypt a BRC-78 encrypted message.
48
+ #
49
+ # @param data [String] the binary encrypted message (from {.encrypt})
50
+ # @param recipient [PrivateKey] the recipient's private key
51
+ # @return [String] the decrypted plaintext (binary)
52
+ # @raise [ArgumentError] if version is wrong, recipient doesn't match, or message is too short
53
+ # @raise [OpenSSL::Cipher::CipherError] if decryption fails (tampered or wrong key)
54
+ def decrypt(data, recipient)
55
+ data = data.b
56
+ raise ArgumentError, "encrypted message too short: #{data.bytesize} bytes (minimum #{HEADER_SIZE})" if data.bytesize < HEADER_SIZE
57
+
58
+ version = data.byteslice(0, 4)
59
+ raise ArgumentError, "message version mismatch: expected #{VERSION.unpack1('H*')}, received #{version.unpack1('H*')}" if version != VERSION
60
+
61
+ sender_pub = PublicKey.from_bytes(data.byteslice(4, 33))
62
+ expected_recipient = data.byteslice(37, 33)
63
+ actual_recipient = recipient.public_key.compressed
64
+
65
+ if expected_recipient != actual_recipient
66
+ raise ArgumentError,
67
+ "the encrypted message expects a recipient public key of #{expected_recipient.unpack1('H*')}, " \
68
+ "but the provided key is #{actual_recipient.unpack1('H*')}"
69
+ end
70
+
71
+ key_id = data.byteslice(70, 32)
72
+ encrypted_payload = data.byteslice(HEADER_SIZE, data.bytesize - HEADER_SIZE)
73
+
74
+ invoice = "2-message encryption-#{[key_id].pack('m0')}"
75
+ sym_key = derive_symmetric_key_for_decrypt(sender_pub, recipient, invoice)
76
+ sym_key.decrypt(encrypted_payload)
77
+ end
78
+
79
+ class << self
80
+ private
81
+
82
+ # Derive the symmetric key for encryption (sender has private key).
83
+ def derive_symmetric_key(sender_priv, recipient_pub, invoice)
84
+ sender_child = sender_priv.derive_child(recipient_pub, invoice)
85
+ recipient_child = recipient_pub.derive_child(sender_priv, invoice)
86
+ shared = sender_child.derive_shared_secret(recipient_child)
87
+ SymmetricKey.new(shared.compressed.byteslice(1, 32))
88
+ end
89
+
90
+ # Derive the symmetric key for decryption (recipient has private key).
91
+ def derive_symmetric_key_for_decrypt(sender_pub, recipient_priv, invoice)
92
+ sender_child = sender_pub.derive_child(recipient_priv, invoice)
93
+ recipient_child = recipient_priv.derive_child(sender_pub, invoice)
94
+ shared = recipient_child.derive_shared_secret(sender_child)
95
+ SymmetricKey.new(shared.compressed.byteslice(1, 32))
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -62,8 +62,7 @@ module BSV
62
62
  # @param depth [Integer] derivation depth
63
63
  # @param parent_fingerprint [String] 4-byte parent fingerprint
64
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)
65
+ def initialize(key:, chain_code:, version:, depth: 0, parent_fingerprint: "\x00\x00\x00\x00".b, child_number: 0)
67
66
  @key = key
68
67
  @chain_code = chain_code
69
68
  @depth = depth
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Primitives
5
+ # A set of Shamir's Secret Sharing shares derived from a private key.
6
+ #
7
+ # Holds the evaluation points (shares), a reconstruction threshold, and an
8
+ # integrity string for verifying that recombined shares produce the correct
9
+ # key. Supports serialisation to and from a human-readable backup format.
10
+ #
11
+ # Backup format per share: "Base58(x).Base58(y).threshold.integrity"
12
+ #
13
+ # This format is compatible with the Go and TypeScript reference SDKs.
14
+ #
15
+ # @example Round-trip through backup format
16
+ # shares = key.to_key_shares(2, 5)
17
+ # backup = shares.to_backup_format
18
+ # rebuilt = KeyShares.from_backup_format(backup[0..1])
19
+ # key = PrivateKey.from_key_shares(rebuilt)
20
+ class KeyShares
21
+ # @return [Array<PointInFiniteField>] the share points
22
+ attr_reader :points
23
+
24
+ # @return [Integer] the minimum number of shares required to reconstruct the key
25
+ attr_reader :threshold
26
+
27
+ # @return [String] first 8 hex characters of the Hash160 of the compressed public key
28
+ attr_reader :integrity
29
+
30
+ # @param points [Array<PointInFiniteField>] share points
31
+ # @param threshold [Integer] reconstruction threshold
32
+ # @param integrity [String] integrity check string
33
+ def initialize(points, threshold, integrity)
34
+ @points = points
35
+ @threshold = threshold
36
+ @integrity = integrity
37
+ end
38
+
39
+ # Deserialise shares from backup format strings.
40
+ #
41
+ # Each string must have the form "Base58(x).Base58(y).threshold.integrity".
42
+ # All shares must agree on threshold and integrity.
43
+ #
44
+ # @param shares [Array<String>] backup-format share strings
45
+ # @return [KeyShares]
46
+ # @raise [ArgumentError] if any share is malformed or shares are inconsistent
47
+ def self.from_backup_format(shares)
48
+ threshold = 0
49
+ integrity = ''
50
+ points = shares.each_with_index.map do |share, idx|
51
+ parts = share.split('.', -1)
52
+ raise ArgumentError, "invalid share format in share #{idx}: expected 4 dot-separated parts, got #{share.inspect}" unless parts.length == 4
53
+
54
+ x_str, y_str, t_str, i_str = parts
55
+
56
+ raise ArgumentError, "threshold not found in share #{idx}" if t_str.empty?
57
+ raise ArgumentError, "integrity not found in share #{idx}" if i_str.empty?
58
+
59
+ t_int = Integer(t_str)
60
+
61
+ if idx != 0
62
+ raise ArgumentError, "threshold mismatch in share #{idx}" unless threshold == t_int
63
+ raise ArgumentError, "integrity mismatch in share #{idx}" unless integrity == i_str
64
+ end
65
+
66
+ threshold = t_int
67
+ integrity = i_str
68
+
69
+ PointInFiniteField.from_string("#{x_str}.#{y_str}")
70
+ end
71
+
72
+ new(points, threshold, integrity)
73
+ end
74
+
75
+ # Serialise shares to backup format strings.
76
+ #
77
+ # @return [Array<String>] one backup string per share point
78
+ def to_backup_format
79
+ @points.map { |point| "#{point}.#{@threshold}.#{@integrity}" }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -42,9 +42,7 @@ module BSV
42
42
  # @return [Mnemonic] a new mnemonic with valid checksum
43
43
  # @raise [ArgumentError] if strength is not a valid value
44
44
  def self.generate(strength: 128)
45
- unless VALID_STRENGTHS.include?(strength)
46
- raise ArgumentError, "invalid strength: #{strength}. Must be one of #{VALID_STRENGTHS.join(', ')}"
47
- end
45
+ raise ArgumentError, "invalid strength: #{strength}. Must be one of #{VALID_STRENGTHS.join(', ')}" unless VALID_STRENGTHS.include?(strength)
48
46
 
49
47
  entropy = SecureRandom.random_bytes(strength / 8)
50
48
  from_entropy(entropy)
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module BSV
6
+ module Primitives
7
+ # A point (x, y) in a finite field over the secp256k1 field prime P.
8
+ #
9
+ # Used as a share in Shamir's Secret Sharing Scheme. Both coordinates are
10
+ # reduced modulo P on construction so they always lie in [0, P).
11
+ #
12
+ # Serialisation uses Base58 for each coordinate, joined by a dot, matching
13
+ # the format used by the Go and TypeScript reference SDKs.
14
+ #
15
+ # @example
16
+ # point = PointInFiniteField.new(OpenSSL::BN.new(10), OpenSSL::BN.new(20))
17
+ # str = point.to_s #=> "C.N"
18
+ # back = PointInFiniteField.from_string(str)
19
+ class PointInFiniteField
20
+ # The secp256k1 field prime P.
21
+ P = OpenSSL::BN.new('FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F', 16).freeze
22
+
23
+ # @return [OpenSSL::BN] the x-coordinate, reduced mod P
24
+ attr_reader :x
25
+
26
+ # @return [OpenSSL::BN] the y-coordinate, reduced mod P
27
+ attr_reader :y
28
+
29
+ # @param x [OpenSSL::BN] the x-coordinate
30
+ # @param y [OpenSSL::BN] the y-coordinate
31
+ def initialize(x, y)
32
+ @x = umod(x, P)
33
+ @y = umod(y, P)
34
+ end
35
+
36
+ # Serialise to Base58(x) + "." + Base58(y).
37
+ #
38
+ # @return [String] dot-separated Base58-encoded coordinates
39
+ def to_s
40
+ "#{Base58.encode(bn_to_bytes(@x))}.#{Base58.encode(bn_to_bytes(@y))}"
41
+ end
42
+
43
+ # Deserialise from a "Base58(x).Base58(y)" string.
44
+ #
45
+ # @param str [String] dot-separated Base58-encoded coordinates
46
+ # @return [PointInFiniteField]
47
+ # @raise [ArgumentError] if the string does not contain exactly one dot
48
+ def self.from_string(str)
49
+ parts = str.split('.')
50
+ raise ArgumentError, "invalid point string: expected 'x.y', got #{str.inspect}" unless parts.length == 2
51
+
52
+ x = OpenSSL::BN.new(Base58.decode(parts[0]), 2)
53
+ y = OpenSSL::BN.new(Base58.decode(parts[1]), 2)
54
+ new(x, y)
55
+ end
56
+
57
+ private
58
+
59
+ # Unsigned modulo — always returns a non-negative result in [0, m).
60
+ def umod(n, m)
61
+ result = n % m
62
+ result.negative? ? result + m : result
63
+ end
64
+
65
+ # Convert an OpenSSL::BN to a big-endian binary string, stripping the
66
+ # sign byte that OpenSSL::BN#to_s(2) sometimes prepends.
67
+ def bn_to_bytes(bn)
68
+ bn.to_s(2)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module BSV
6
+ module Primitives
7
+ # A polynomial defined by a set of points, evaluated using Lagrange interpolation.
8
+ #
9
+ # Used in Shamir's Secret Sharing Scheme to split and reconstruct a secret.
10
+ # All arithmetic is performed in the finite field GF(P) where P is the
11
+ # secp256k1 field prime.
12
+ #
13
+ # The secret is encoded as the y-value at x=0. Given +threshold+ distinct
14
+ # points the polynomial can be evaluated at any x by Lagrange interpolation.
15
+ #
16
+ # @example Construct shares from a private key
17
+ # poly = Polynomial.from_private_key(key, threshold: 2)
18
+ # share1 = poly.value_at(OpenSSL::BN.new('1'))
19
+ # share0 = poly.value_at(OpenSSL::BN.new('0')) # recovers the secret
20
+ class Polynomial
21
+ P = PointInFiniteField::P
22
+
23
+ # @return [Array<PointInFiniteField>] the defining points of the polynomial
24
+ attr_reader :points
25
+
26
+ # @return [Integer] the minimum number of shares needed to reconstruct the secret
27
+ attr_reader :threshold
28
+
29
+ # @param points [Array<PointInFiniteField>] defining points
30
+ # @param threshold [Integer] reconstruction threshold (defaults to points.length)
31
+ def initialize(points, threshold = nil)
32
+ @points = points
33
+ @threshold = threshold || points.length
34
+ end
35
+
36
+ # Build a polynomial whose y-intercept (secret) is the private key scalar.
37
+ #
38
+ # The first point is (0, key_scalar). The remaining +threshold-1+ points
39
+ # have random coordinates in [0, P), providing the random coefficients of
40
+ # the underlying polynomial.
41
+ #
42
+ # @param key [PrivateKey] the private key to split
43
+ # @param threshold [Integer] the reconstruction threshold (minimum 2)
44
+ # @return [Polynomial]
45
+ def self.from_private_key(key, threshold:)
46
+ secret_y = key.bn
47
+ points = [PointInFiniteField.new(OpenSSL::BN.new('0'), secret_y)]
48
+
49
+ (threshold - 1).times do
50
+ random_x = OpenSSL::BN.rand(256) % P
51
+ random_y = OpenSSL::BN.rand(256) % P
52
+ points << PointInFiniteField.new(random_x, random_y)
53
+ end
54
+
55
+ new(points, threshold)
56
+ end
57
+
58
+ # Evaluate the polynomial at x using Lagrange interpolation mod P.
59
+ #
60
+ # @param x [OpenSSL::BN] the x value at which to evaluate
61
+ # @return [OpenSSL::BN] the y value, in [0, P)
62
+ def value_at(x)
63
+ y = OpenSSL::BN.new('0')
64
+
65
+ threshold.times do |i|
66
+ term = points[i].y
67
+ threshold.times do |j|
68
+ next if i == j
69
+
70
+ xi = points[i].x
71
+ xj = points[j].x
72
+
73
+ numerator = umod(x - xj, P)
74
+ denominator = umod(xi - xj, P)
75
+ denom_inv = denominator.mod_inverse(P)
76
+
77
+ fraction = numerator.mod_mul(denom_inv, P)
78
+ term = term.mod_mul(fraction, P)
79
+ end
80
+ y = y.mod_add(term, P)
81
+ end
82
+
83
+ y
84
+ end
85
+
86
+ private
87
+
88
+ # Unsigned modulo — always returns a result in [0, P).
89
+ def umod(n, m)
90
+ result = n % m
91
+ result.negative? ? result + m : result
92
+ end
93
+ end
94
+ end
95
+ end
@@ -74,9 +74,7 @@ module BSV
74
74
  def self.from_wif(wif_string)
75
75
  data = Base58.check_decode(wif_string)
76
76
  prefix = data[0]
77
- unless [MAINNET_PREFIX, TESTNET_PREFIX].include?(prefix)
78
- raise ArgumentError, "unknown WIF network prefix: 0x#{prefix.unpack1('H*')}"
79
- end
77
+ raise ArgumentError, "unknown WIF network prefix: 0x#{prefix.unpack1('H*')}" unless [MAINNET_PREFIX, TESTNET_PREFIX].include?(prefix)
80
78
 
81
79
  case data.length
82
80
  when 33
@@ -168,6 +166,105 @@ module BSV
168
166
  def sign(hash)
169
167
  ECDSA.sign(hash, @bn)
170
168
  end
169
+
170
+ # Split this private key into Shamir's Secret Sharing shares.
171
+ #
172
+ # Generates +total_shares+ evaluation points on a random polynomial whose
173
+ # y-intercept encodes this key. Any +threshold+ of them suffice to
174
+ # reconstruct the key. X-coordinates are derived via HMAC-SHA-512 over a
175
+ # 64-byte random seed, ensuring uniqueness even under partial RNG failure.
176
+ #
177
+ # @param threshold [Integer] minimum shares needed to reconstruct (>= 2)
178
+ # @param total_shares [Integer] total shares to generate (>= threshold)
179
+ # @return [KeyShares]
180
+ # @raise [ArgumentError] if parameters are out of range
181
+ def to_key_shares(threshold, total_shares)
182
+ raise ArgumentError, 'threshold must be an integer' unless threshold.is_a?(Integer)
183
+ raise ArgumentError, 'total_shares must be an integer' unless total_shares.is_a?(Integer)
184
+ raise ArgumentError, 'threshold must be at least 2' if threshold < 2
185
+ raise ArgumentError, 'total_shares must be at least 2' if total_shares < 2
186
+ raise ArgumentError, 'threshold must be <= total_shares' if threshold > total_shares
187
+
188
+ poly = Polynomial.from_private_key(self, threshold: threshold)
189
+
190
+ seed = SecureRandom.random_bytes(64)
191
+ used_x = {}
192
+ points = []
193
+
194
+ total_shares.times do |i|
195
+ x = nil
196
+ attempts = 0
197
+ loop do
198
+ counter_bytes = [i, attempts].pack('N*') + SecureRandom.random_bytes(32)
199
+ h = Digest.hmac_sha512(seed, counter_bytes)
200
+ candidate = OpenSSL::BN.new(h.unpack1('H*'), 16) % PointInFiniteField::P
201
+
202
+ attempts += 1
203
+ raise ArgumentError, 'failed to generate unique x-coordinate after 5 attempts' if attempts > 5
204
+
205
+ next if candidate.zero? || used_x.key?(candidate.to_s)
206
+
207
+ x = candidate
208
+ break
209
+ end
210
+
211
+ used_x[x.to_s] = true
212
+ y = poly.value_at(x)
213
+ points << PointInFiniteField.new(x, y)
214
+ end
215
+
216
+ integrity = public_key.hash160[0, 4].unpack1('H*')
217
+ KeyShares.new(points, threshold, integrity)
218
+ end
219
+
220
+ # Serialise this key as Shamir backup share strings.
221
+ #
222
+ # Convenience wrapper around {#to_key_shares} and {KeyShares#to_backup_format}.
223
+ #
224
+ # @param threshold [Integer] minimum shares needed to reconstruct
225
+ # @param total_shares [Integer] total shares to generate
226
+ # @return [Array<String>] backup-format share strings
227
+ def to_backup_shares(threshold, total_shares)
228
+ to_key_shares(threshold, total_shares).to_backup_format
229
+ end
230
+
231
+ # Reconstruct a private key from a {KeyShares} object.
232
+ #
233
+ # Evaluates the Lagrange polynomial at x=0 to recover the secret, then
234
+ # checks the integrity hash against the reconstructed public key.
235
+ #
236
+ # @param key_shares [KeyShares] the shares to combine
237
+ # @return [PrivateKey] the reconstructed private key
238
+ # @raise [ArgumentError] if there are too few shares, duplicates, or integrity fails
239
+ def self.from_key_shares(key_shares)
240
+ points = key_shares.points
241
+ threshold = key_shares.threshold
242
+ integrity = key_shares.integrity
243
+
244
+ raise ArgumentError, 'threshold must be at least 2' if threshold < 2
245
+ raise ArgumentError, "at least #{threshold} shares are required" if points.length < threshold
246
+
247
+ # Guard against duplicate x-coordinates
248
+ xs = points.first(threshold).map { |p| p.x.to_s }
249
+ raise ArgumentError, 'duplicate share detected; each share must be unique' if xs.length != xs.uniq.length
250
+
251
+ poly = Polynomial.new(points.first(threshold), threshold)
252
+ secret = poly.value_at(OpenSSL::BN.new('0'))
253
+ key = new(secret)
254
+
255
+ actual_integrity = key.public_key.hash160[0, 4].unpack1('H*')
256
+ raise ArgumentError, 'integrity hash mismatch — shares may be corrupt or belong to a different key' unless actual_integrity == integrity
257
+
258
+ key
259
+ end
260
+
261
+ # Reconstruct a private key from backup-format share strings.
262
+ #
263
+ # @param shares [Array<String>] backup-format share strings
264
+ # @return [PrivateKey]
265
+ def self.from_backup_shares(shares)
266
+ from_key_shares(KeyShares.from_backup_format(shares))
267
+ end
171
268
  end
172
269
  end
173
270
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module BSV
6
+ module Primitives
7
+ # BRC-77 signed messages.
8
+ #
9
+ # Provides authenticated messaging using BRC-42 derived signing keys.
10
+ # The sender proves their identity to a specific recipient (or anyone)
11
+ # without encrypting the message content.
12
+ #
13
+ # @example Sign and verify for a specific recipient
14
+ # sig = SignedMessage.sign(message, sender_priv, recipient_pub)
15
+ # SignedMessage.verify(message, sig, recipient_priv) #=> true
16
+ #
17
+ # @example Sign for anyone to verify
18
+ # sig = SignedMessage.sign(message, sender_priv)
19
+ # SignedMessage.verify(message, sig) #=> true
20
+ #
21
+ # @see https://github.com/bitcoin-sv/BRCs/blob/master/peer-to-peer/0077.md
22
+ module SignedMessage
23
+ # Protocol version bytes: "BB3\x01"
24
+ VERSION = "\x42\x42\x33\x01".b.freeze
25
+
26
+ module_function
27
+
28
+ # Sign a message using the BRC-77 protocol.
29
+ #
30
+ # @param message [String] the message to sign
31
+ # @param signer [PrivateKey] the sender's private key
32
+ # @param verifier [PublicKey, nil] the recipient's public key (nil for anyone-can-verify)
33
+ # @return [String] binary signed message (version + keys + key_id + DER signature)
34
+ def sign(message, signer, verifier = nil)
35
+ anyone = verifier.nil?
36
+ verifier = PrivateKey.new(OpenSSL::BN.new(1)).public_key if anyone
37
+
38
+ key_id = SecureRandom.random_bytes(32)
39
+ invoice = "2-message signing-#{[key_id].pack('m0')}"
40
+
41
+ signing_key = signer.derive_child(verifier, invoice)
42
+ hash = Digest.sha256(message.b)
43
+ signature = signing_key.sign(hash)
44
+
45
+ VERSION +
46
+ signer.public_key.compressed +
47
+ (anyone ? "\x00".b : verifier.compressed) +
48
+ key_id +
49
+ signature.to_der
50
+ end
51
+
52
+ # Verify a BRC-77 signed message.
53
+ #
54
+ # @param message [String] the original message
55
+ # @param sig [String] the binary signature (from {.sign})
56
+ # @param recipient [PrivateKey, nil] the recipient's private key (nil for anyone-can-verify)
57
+ # @return [Boolean] true if the signature is valid
58
+ # @raise [ArgumentError] if the version is wrong, recipient is required but missing, or recipient doesn't match
59
+ def verify(message, sig, recipient = nil)
60
+ sig = sig.b
61
+ raise ArgumentError, "signed message too short: #{sig.bytesize} bytes" if sig.bytesize < 38
62
+
63
+ version = sig.byteslice(0, 4)
64
+ raise ArgumentError, "message version mismatch: expected #{VERSION.unpack1('H*')}, received #{version.unpack1('H*')}" if version != VERSION
65
+
66
+ sender_pub = PublicKey.from_bytes(sig.byteslice(4, 33))
67
+ verifier_first = sig.getbyte(37)
68
+
69
+ if verifier_first.zero?
70
+ # Anyone-can-verify mode
71
+ recipient = PrivateKey.new(OpenSSL::BN.new(1))
72
+ key_id_offset = 38
73
+ else
74
+ # Specific recipient
75
+ verifier_pub_bytes = sig.byteslice(37, 33)
76
+ verifier_pub_hex = verifier_pub_bytes.unpack1('H*')
77
+
78
+ if recipient.nil?
79
+ raise ArgumentError,
80
+ "this signature can only be verified with knowledge of a specific private key. The associated public key is: #{verifier_pub_hex}"
81
+ end
82
+
83
+ recipient_pub_hex = recipient.public_key.compressed.unpack1('H*')
84
+ if verifier_pub_hex != recipient_pub_hex
85
+ raise ArgumentError,
86
+ "the recipient public key is #{recipient_pub_hex} but the signature requires the recipient to have public key #{verifier_pub_hex}"
87
+ end
88
+
89
+ key_id_offset = 70
90
+ end
91
+
92
+ key_id = sig.byteslice(key_id_offset, 32)
93
+ der_bytes = sig.byteslice(key_id_offset + 32, sig.bytesize - key_id_offset - 32)
94
+
95
+ invoice = "2-message signing-#{[key_id].pack('m0')}"
96
+ signing_pub = sender_pub.derive_child(recipient, invoice)
97
+
98
+ signature = Signature.from_der(der_bytes)
99
+ hash = Digest.sha256(message.b)
100
+ ECDSA.verify(hash, signature, signing_pub.point)
101
+ end
102
+ end
103
+ end
104
+ end