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.
- checksums.yaml +4 -4
- data/lib/bsv/network/broadcast_response.rb +1 -2
- data/lib/bsv/primitives/bsm.rb +2 -6
- data/lib/bsv/primitives/curve.rb +1 -2
- data/lib/bsv/primitives/encrypted_message.rb +100 -0
- data/lib/bsv/primitives/extended_key.rb +1 -2
- data/lib/bsv/primitives/key_shares.rb +83 -0
- data/lib/bsv/primitives/mnemonic.rb +1 -3
- data/lib/bsv/primitives/point_in_finite_field.rb +72 -0
- data/lib/bsv/primitives/polynomial.rb +95 -0
- data/lib/bsv/primitives/private_key.rb +100 -3
- data/lib/bsv/primitives/signed_message.rb +104 -0
- data/lib/bsv/primitives/symmetric_key.rb +128 -0
- data/lib/bsv/primitives.rb +18 -12
- data/lib/bsv/script/interpreter/interpreter.rb +1 -3
- data/lib/bsv/script/interpreter/operations/bitwise.rb +1 -3
- data/lib/bsv/script/interpreter/operations/crypto.rb +3 -9
- data/lib/bsv/script/interpreter/operations/flow_control.rb +2 -6
- data/lib/bsv/script/interpreter/operations/splice.rb +1 -3
- data/lib/bsv/script/interpreter/script_number.rb +2 -7
- data/lib/bsv/script/script.rb +252 -1
- data/lib/bsv/transaction/beef.rb +1 -4
- data/lib/bsv/transaction/transaction.rb +123 -45
- data/lib/bsv/transaction/transaction_input.rb +1 -2
- data/lib/bsv/transaction/transaction_output.rb +1 -2
- data/lib/bsv/transaction/var_int.rb +4 -16
- data/lib/bsv/transaction.rb +14 -14
- data/lib/bsv/version.rb +1 -1
- data/lib/bsv/wallet_interface/errors/invalid_hmac_error.rb +11 -0
- data/lib/bsv/wallet_interface/errors/invalid_parameter_error.rb +14 -0
- data/lib/bsv/wallet_interface/errors/invalid_signature_error.rb +11 -0
- data/lib/bsv/wallet_interface/errors/unsupported_action_error.rb +11 -0
- data/lib/bsv/wallet_interface/errors/wallet_error.rb +14 -0
- data/lib/bsv/wallet_interface/interface.rb +384 -0
- data/lib/bsv/wallet_interface/key_deriver.rb +142 -0
- data/lib/bsv/wallet_interface/memory_store.rb +115 -0
- data/lib/bsv/wallet_interface/proto_wallet.rb +361 -0
- data/lib/bsv/wallet_interface/storage_adapter.rb +51 -0
- data/lib/bsv/wallet_interface/validators.rb +126 -0
- data/lib/bsv/wallet_interface/version.rb +7 -0
- data/lib/bsv/wallet_interface/wallet_client.rb +486 -0
- data/lib/bsv/wallet_interface.rb +25 -0
- data/lib/bsv-wallet.rb +4 -0
- metadata +24 -3
- /data/{LICENCE → LICENSE} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 61435802c338981fb3f6fd8d5881aac9aa83b1cdb261f02dc2a9e2ffa1eb8fa7
|
|
4
|
+
data.tar.gz: '0159ac16ce996bc45503c6342e0ec763d82285968d9b614c209b4c38fc8f2980'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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]
|
data/lib/bsv/primitives/bsm.rb
CHANGED
|
@@ -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)
|
data/lib/bsv/primitives/curve.rb
CHANGED
|
@@ -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
|