bsv-sdk 0.2.0 → 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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/lib/bsv/network/broadcast_response.rb +1 -2
  4. data/lib/bsv/primitives/bsm.rb +2 -6
  5. data/lib/bsv/primitives/curve.rb +1 -2
  6. data/lib/bsv/primitives/encrypted_message.rb +100 -0
  7. data/lib/bsv/primitives/extended_key.rb +1 -2
  8. data/lib/bsv/primitives/key_shares.rb +83 -0
  9. data/lib/bsv/primitives/mnemonic.rb +1 -3
  10. data/lib/bsv/primitives/point_in_finite_field.rb +72 -0
  11. data/lib/bsv/primitives/polynomial.rb +95 -0
  12. data/lib/bsv/primitives/private_key.rb +101 -5
  13. data/lib/bsv/primitives/signed_message.rb +104 -0
  14. data/lib/bsv/primitives/symmetric_key.rb +128 -0
  15. data/lib/bsv/primitives.rb +18 -12
  16. data/lib/bsv/script/interpreter/interpreter.rb +1 -3
  17. data/lib/bsv/script/interpreter/operations/bitwise.rb +1 -3
  18. data/lib/bsv/script/interpreter/operations/crypto.rb +3 -9
  19. data/lib/bsv/script/interpreter/operations/flow_control.rb +2 -6
  20. data/lib/bsv/script/interpreter/operations/splice.rb +1 -3
  21. data/lib/bsv/script/interpreter/script_number.rb +2 -7
  22. data/lib/bsv/script/script.rb +256 -1
  23. data/lib/bsv/transaction/beef.rb +8 -11
  24. data/lib/bsv/transaction/transaction.rb +131 -59
  25. data/lib/bsv/transaction/transaction_input.rb +1 -2
  26. data/lib/bsv/transaction/transaction_output.rb +1 -2
  27. data/lib/bsv/transaction/var_int.rb +4 -16
  28. data/lib/bsv/transaction.rb +14 -14
  29. data/lib/bsv/version.rb +1 -1
  30. data/lib/bsv/wallet_interface/errors/invalid_hmac_error.rb +11 -0
  31. data/lib/bsv/wallet_interface/errors/invalid_parameter_error.rb +14 -0
  32. data/lib/bsv/wallet_interface/errors/invalid_signature_error.rb +11 -0
  33. data/lib/bsv/wallet_interface/errors/unsupported_action_error.rb +11 -0
  34. data/lib/bsv/wallet_interface/errors/wallet_error.rb +14 -0
  35. data/lib/bsv/wallet_interface/interface.rb +384 -0
  36. data/lib/bsv/wallet_interface/key_deriver.rb +142 -0
  37. data/lib/bsv/wallet_interface/memory_store.rb +115 -0
  38. data/lib/bsv/wallet_interface/proto_wallet.rb +361 -0
  39. data/lib/bsv/wallet_interface/storage_adapter.rb +51 -0
  40. data/lib/bsv/wallet_interface/validators.rb +126 -0
  41. data/lib/bsv/wallet_interface/version.rb +7 -0
  42. data/lib/bsv/wallet_interface/wallet_client.rb +486 -0
  43. data/lib/bsv/wallet_interface.rb +25 -0
  44. data/lib/bsv-wallet.rb +4 -0
  45. metadata +24 -3
  46. /data/{LICENCE → LICENSE} +0 -0
@@ -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
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'securerandom'
5
+
6
+ module BSV
7
+ module Primitives
8
+ # AES-256-GCM symmetric encryption.
9
+ #
10
+ # Provides authenticated encryption matching the interface used by the
11
+ # TS, Go, and Python reference SDKs. The wire format is:
12
+ #
13
+ # |--- 32-byte IV ---|--- ciphertext ---|--- 16-byte auth tag ---|
14
+ #
15
+ # All three reference SDKs use a 32-byte IV (non-standard but
16
+ # cross-SDK compatible) and 16-byte authentication tag.
17
+ #
18
+ # @example Round-trip encryption
19
+ # key = BSV::Primitives::SymmetricKey.from_random
20
+ # encrypted = key.encrypt('hello world')
21
+ # key.decrypt(encrypted) #=> "hello world"
22
+ class SymmetricKey
23
+ IV_SIZE = 32
24
+ TAG_SIZE = 16
25
+ KEY_SIZE = 32
26
+
27
+ # @param key_bytes [String] 32-byte binary key (shorter keys are left-zero-padded)
28
+ # @raise [ArgumentError] if key is empty or longer than 32 bytes
29
+ def initialize(key_bytes)
30
+ key_bytes = key_bytes.b
31
+ raise ArgumentError, 'key must not be empty' if key_bytes.empty?
32
+ raise ArgumentError, "key must be at most #{KEY_SIZE} bytes, got #{key_bytes.bytesize}" if key_bytes.bytesize > KEY_SIZE
33
+
34
+ @key = if key_bytes.bytesize < KEY_SIZE
35
+ ("\x00".b * (KEY_SIZE - key_bytes.bytesize)) + key_bytes
36
+ else
37
+ key_bytes
38
+ end
39
+ end
40
+
41
+ # Generate a random symmetric key.
42
+ #
43
+ # @return [SymmetricKey]
44
+ def self.from_random
45
+ new(SecureRandom.random_bytes(KEY_SIZE))
46
+ end
47
+
48
+ # Derive a symmetric key from an ECDH shared secret.
49
+ #
50
+ # Computes the shared point between the two parties and uses the
51
+ # X-coordinate as the key material. The X-coordinate may be 31 or
52
+ # 32 bytes; shorter values are left-zero-padded automatically.
53
+ #
54
+ # @example Alice and Bob derive the same key
55
+ # alice_key = SymmetricKey.from_ecdh(alice_priv, bob_pub)
56
+ # bob_key = SymmetricKey.from_ecdh(bob_priv, alice_pub)
57
+ # alice_key.to_bytes == bob_key.to_bytes #=> true
58
+ #
59
+ # @param private_key [PrivateKey] one party's private key
60
+ # @param public_key [PublicKey] the other party's public key
61
+ # @return [SymmetricKey]
62
+ def self.from_ecdh(private_key, public_key)
63
+ shared = private_key.derive_shared_secret(public_key)
64
+ # X-coordinate = bytes 1..32 of the compressed point (skip the 02/03 prefix)
65
+ x_bytes = shared.compressed.byteslice(1, 32)
66
+ new(x_bytes)
67
+ end
68
+
69
+ # Encrypt a message with AES-256-GCM.
70
+ #
71
+ # Generates a random 32-byte IV per call. Returns the concatenation
72
+ # of IV, ciphertext, and 16-byte authentication tag.
73
+ #
74
+ # @param plaintext [String] the message to encrypt
75
+ # @return [String] binary string: IV (32) + ciphertext + auth tag (16)
76
+ def encrypt(plaintext)
77
+ iv = SecureRandom.random_bytes(IV_SIZE)
78
+
79
+ cipher = OpenSSL::Cipher.new('aes-256-gcm')
80
+ cipher.encrypt
81
+ cipher.key = @key
82
+ cipher.iv_len = IV_SIZE
83
+ cipher.iv = iv
84
+ cipher.auth_data = ''.b
85
+
86
+ plaintext_bytes = plaintext.b
87
+ ciphertext = plaintext_bytes.empty? ? cipher.final : cipher.update(plaintext_bytes) + cipher.final
88
+ tag = cipher.auth_tag(TAG_SIZE)
89
+
90
+ iv + ciphertext + tag
91
+ end
92
+
93
+ # Decrypt an AES-256-GCM encrypted message.
94
+ #
95
+ # Expects the wire format: IV (32) + ciphertext + auth tag (16).
96
+ #
97
+ # @param data [String] the encrypted message
98
+ # @return [String] the decrypted plaintext (binary)
99
+ # @raise [ArgumentError] if the data is too short
100
+ # @raise [OpenSSL::Cipher::CipherError] if authentication fails (wrong key or tampered data)
101
+ def decrypt(data)
102
+ data = data.b
103
+ raise ArgumentError, "ciphertext too short: #{data.bytesize} bytes (minimum #{IV_SIZE + TAG_SIZE})" if data.bytesize < IV_SIZE + TAG_SIZE
104
+
105
+ iv = data.byteslice(0, IV_SIZE)
106
+ tag = data.byteslice(-TAG_SIZE, TAG_SIZE)
107
+ ciphertext = data.byteslice(IV_SIZE, data.bytesize - IV_SIZE - TAG_SIZE)
108
+
109
+ decipher = OpenSSL::Cipher.new('aes-256-gcm')
110
+ decipher.decrypt
111
+ decipher.key = @key
112
+ decipher.iv_len = IV_SIZE
113
+ decipher.iv = iv
114
+ decipher.auth_tag = tag
115
+ decipher.auth_data = ''.b
116
+
117
+ ciphertext.empty? ? decipher.final : decipher.update(ciphertext) + decipher.final
118
+ end
119
+
120
+ # Return the raw key bytes.
121
+ #
122
+ # @return [String] 32-byte binary key
123
+ def to_bytes
124
+ @key.dup
125
+ end
126
+ end
127
+ end
128
+ end
@@ -7,17 +7,23 @@ module BSV
7
7
  # HD key derivation (BIP-32), and mnemonic phrase generation (BIP-39).
8
8
  # All cryptography uses Ruby's stdlib +openssl+ — no external gems.
9
9
  module Primitives
10
- autoload :Curve, 'bsv/primitives/curve'
11
- autoload :Digest, 'bsv/primitives/digest'
12
- autoload :Base58, 'bsv/primitives/base58'
13
- autoload :Signature, 'bsv/primitives/signature'
14
- autoload :ECDSA, 'bsv/primitives/ecdsa'
15
- autoload :ECIES, 'bsv/primitives/ecies'
16
- autoload :BSM, 'bsv/primitives/bsm'
17
- autoload :Schnorr, 'bsv/primitives/schnorr'
18
- autoload :PublicKey, 'bsv/primitives/public_key'
19
- autoload :PrivateKey, 'bsv/primitives/private_key'
20
- autoload :ExtendedKey, 'bsv/primitives/extended_key'
21
- autoload :Mnemonic, 'bsv/primitives/mnemonic'
10
+ autoload :Curve, 'bsv/primitives/curve'
11
+ autoload :Digest, 'bsv/primitives/digest'
12
+ autoload :Base58, 'bsv/primitives/base58'
13
+ autoload :Signature, 'bsv/primitives/signature'
14
+ autoload :ECDSA, 'bsv/primitives/ecdsa'
15
+ autoload :ECIES, 'bsv/primitives/ecies'
16
+ autoload :BSM, 'bsv/primitives/bsm'
17
+ autoload :Schnorr, 'bsv/primitives/schnorr'
18
+ autoload :PublicKey, 'bsv/primitives/public_key'
19
+ autoload :PrivateKey, 'bsv/primitives/private_key'
20
+ autoload :ExtendedKey, 'bsv/primitives/extended_key'
21
+ autoload :Mnemonic, 'bsv/primitives/mnemonic'
22
+ autoload :SymmetricKey, 'bsv/primitives/symmetric_key'
23
+ autoload :SignedMessage, 'bsv/primitives/signed_message'
24
+ autoload :EncryptedMessage, 'bsv/primitives/encrypted_message'
25
+ autoload :PointInFiniteField, 'bsv/primitives/point_in_finite_field'
26
+ autoload :Polynomial, 'bsv/primitives/polynomial'
27
+ autoload :KeyShares, 'bsv/primitives/key_shares'
22
28
  end
23
29
  end
@@ -96,9 +96,7 @@ module BSV
96
96
  break if @early_return && script_idx == 1
97
97
 
98
98
  # Between scripts: verify conditionals balanced
99
- unless @cond_stack.empty?
100
- raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'unbalanced conditional')
101
- end
99
+ raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'unbalanced conditional') unless @cond_stack.empty?
102
100
 
103
101
  # Clear alt stack between scripts
104
102
  @astack.clear
@@ -55,9 +55,7 @@ module BSV
55
55
  def pop_equal_length_pair
56
56
  a = @dstack.pop_bytes
57
57
  b = @dstack.pop_bytes
58
- if a.bytesize != b.bytesize
59
- raise ScriptError.new(ScriptErrorCode::INVALID_INPUT_LENGTH, 'byte arrays are not the same length')
60
- end
58
+ raise ScriptError.new(ScriptErrorCode::INVALID_INPUT_LENGTH, 'byte arrays are not the same length') if a.bytesize != b.bytesize
61
59
 
62
60
  [a, b]
63
61
  end
@@ -52,9 +52,7 @@ module BSV
52
52
  result = verify_checksig(full_sig, pubkey_bytes)
53
53
 
54
54
  # NULLFAIL: non-empty signature that failed verification
55
- unless result
56
- raise ScriptError.new(ScriptErrorCode::SIG_NULLFAIL, 'non-empty signature failed verification')
57
- end
55
+ raise ScriptError.new(ScriptErrorCode::SIG_NULLFAIL, 'non-empty signature failed verification') unless result
58
56
 
59
57
  @dstack.push_bool(true)
60
58
  end
@@ -85,18 +83,14 @@ module BSV
85
83
 
86
84
  # Dummy element (off-by-one bug compatibility)
87
85
  dummy = @dstack.pop_bytes
88
- unless dummy.empty?
89
- raise ScriptError.new(ScriptErrorCode::SIG_NULLDUMMY, 'CHECKMULTISIG dummy element must be empty')
90
- end
86
+ raise ScriptError.new(ScriptErrorCode::SIG_NULLDUMMY, 'CHECKMULTISIG dummy element must be empty') unless dummy.empty?
91
87
 
92
88
  success = multisig_match?(signatures, pubkeys)
93
89
 
94
90
  # NULLFAIL: if failed, all signatures must be empty
95
91
  unless success
96
92
  signatures.each do |sig|
97
- unless sig.empty?
98
- raise ScriptError.new(ScriptErrorCode::SIG_NULLFAIL, 'non-empty signature failed verification')
99
- end
93
+ raise ScriptError.new(ScriptErrorCode::SIG_NULLFAIL, 'non-empty signature failed verification') unless sig.empty?
100
94
  end
101
95
  end
102
96
 
@@ -30,9 +30,7 @@ module BSV
30
30
 
31
31
  # OP_ELSE: toggle conditional branch (only one ELSE per IF after genesis)
32
32
  def op_else
33
- if @cond_stack.empty?
34
- raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'OP_ELSE without matching OP_IF')
35
- end
33
+ raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'OP_ELSE without matching OP_IF') if @cond_stack.empty?
36
34
 
37
35
  # After genesis: only one ELSE per IF
38
36
  raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'duplicate OP_ELSE') if @else_stack.pop
@@ -47,9 +45,7 @@ module BSV
47
45
 
48
46
  # OP_ENDIF: close conditional block
49
47
  def op_endif
50
- if @cond_stack.empty?
51
- raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'OP_ENDIF without matching OP_IF')
52
- end
48
+ raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'OP_ENDIF without matching OP_IF') if @cond_stack.empty?
53
49
 
54
50
  @cond_stack.pop
55
51
  @else_stack.pop
@@ -44,9 +44,7 @@ module BSV
44
44
  size = @dstack.pop_int.to_i32
45
45
  data = @dstack.pop_bytes
46
46
 
47
- if size.negative?
48
- raise ScriptError.new(ScriptErrorCode::INVALID_INPUT_LENGTH, 'OP_NUM2BIN: size is negative')
49
- end
47
+ raise ScriptError.new(ScriptErrorCode::INVALID_INPUT_LENGTH, 'OP_NUM2BIN: size is negative') if size.negative?
50
48
 
51
49
  minimal = ScriptNumber.minimally_encode(data)
52
50
 
@@ -44,10 +44,7 @@ module BSV
44
44
  return new(0) if bytes.empty?
45
45
 
46
46
  if bytes.bytesize > max_length
47
- raise ScriptError.new(
48
- ScriptErrorCode::NUMBER_TOO_BIG,
49
- "script number overflow: #{bytes.bytesize} > #{max_length}"
50
- )
47
+ raise ScriptError.new ScriptErrorCode::NUMBER_TOO_BIG, "script number overflow: #{bytes.bytesize} > #{max_length}"
51
48
  end
52
49
 
53
50
  check_minimal_encoding!(bytes) if require_minimal
@@ -202,9 +199,7 @@ module BSV
202
199
  return if msb.anybits?(0x7f)
203
200
 
204
201
  # Single byte that is pure sign/zero (0x00 or 0x80) — not minimal
205
- if bytes.bytesize == 1
206
- raise ScriptError.new(ScriptErrorCode::MINIMAL_DATA, 'non-minimal script number encoding')
207
- end
202
+ raise ScriptError.new(ScriptErrorCode::MINIMAL_DATA, 'non-minimal script number encoding') if bytes.bytesize == 1
208
203
 
209
204
  # Padding is justified if second-to-last byte has high bit set
210
205
  return if bytes.getbyte(bytes.bytesize - 2) & 0x80 != 0