diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..d4c460d6 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,2 @@ +ARG RUBY_VERSION=3.2.2 +FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..1b8f421f --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,26 @@ +{ + "name": "Ruby Bitcoin Development", + "dockerFile": "Dockerfile", + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "Shopify.ruby-lsp" + ], + "settings": { + "editor.formatOnSave": true, + "ruby.useBundler": true, + "ruby.useLanguageServer": true, + "ruby.lint": { + "rubocop": true + } + } + } + }, + "forwardPorts": [], + "postCreateCommand": "bundle install", + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 71814124..198291a3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,11 @@ spec-rspec/examples.txt # emacs *~ \#*\# -.\#* \ No newline at end of file +.\#* + +# VS Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index f2557c91..067124cf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -12,17 +12,24 @@ GEM ast (2.4.0) bacon (1.2.0) byebug (10.0.2) - coderay (1.1.2) + coderay (1.1.3) + debug (1.9.2) + irb (~> 1.10) + reline (>= 0.3.8) diff-lcs (1.3) docile (1.3.1) eventmachine (1.2.7) - ffi (1.9.25) - ffi-compiler (1.0.1) - ffi (>= 1.0.0) + ffi (1.17.0) + ffi-compiler (1.3.2) + ffi (>= 1.15.5) rake + io-console (0.7.2) + irb (1.14.1) + rdoc (>= 4.0.0) + reline (>= 0.4.2) jaro_winkler (1.5.1) - json (2.1.0) - method_source (0.9.0) + json (2.8.2) + method_source (0.9.2) minitest (5.11.3) parallel (1.12.1) parser (2.5.1.2) @@ -34,8 +41,14 @@ GEM pry-byebug (3.6.0) byebug (~> 10.0) pry (~> 0.10) + psych (5.2.0) + stringio rainbow (3.0.0) - rake (12.3.1) + rake (12.3.3) + rdoc (6.8.1) + psych (>= 4.0.0) + reline (0.5.11) + io-console (~> 0.5) rspec (3.7.0) rspec-core (~> 3.7.0) rspec-expectations (~> 3.7.0) @@ -58,13 +71,15 @@ GEM ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) ruby-progressbar (1.9.0) - scrypt (3.0.5) + scrypt (3.0.8) ffi-compiler (>= 1.0, < 2.0) + rake (>= 9, < 14) simplecov (0.16.1) docile (~> 1.1) json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) + stringio (3.1.2) unicode-display_width (1.4.0) PLATFORMS @@ -73,6 +88,7 @@ PLATFORMS DEPENDENCIES bacon (~> 1.2.0) bitcoin-ruby! + debug minitest (~> 5.11.3) pry (~> 0.11.3) pry-byebug (~> 3.6.0) @@ -82,4 +98,4 @@ DEPENDENCIES simplecov (~> 0.16.1) BUNDLED WITH - 1.17.3 + 2.5.23 diff --git a/bitcoin-ruby.gemspec b/bitcoin-ruby.gemspec index 1301b1d5..18903226 100644 --- a/bitcoin-ruby.gemspec +++ b/bitcoin-ruby.gemspec @@ -24,4 +24,8 @@ Gem::Specification.new do |s| s.add_runtime_dependency 'ffi' s.add_runtime_dependency 'scrypt' # required by Litecoin s.add_runtime_dependency 'eventmachine' # required for connection code + + # Add development dependencies + s.add_development_dependency 'rspec', '~> 3.12' + s.add_development_dependency 'debug' end diff --git a/lib/bitcoin.rb b/lib/bitcoin.rb index 8848a130..3e579db4 100644 --- a/lib/bitcoin.rb +++ b/lib/bitcoin.rb @@ -12,7 +12,7 @@ module Bitcoin # deprecated in favor of a unification of Fixnum and BigInteger named Integer. # Since this project strivers for backwards-compatability, we determine the # appropriate class to use at initialization. - # + # # This avoids annoying deprecation warnings on newer versions for ourselves # and library consumers. Integer = @@ -29,6 +29,7 @@ module Bitcoin autoload :Script, 'bitcoin/script' autoload :VERSION, 'bitcoin/version' autoload :Key, 'bitcoin/key' + autoload :PKeyEC, 'bitcoin/pkey_ec' autoload :ExtKey, 'bitcoin/ext_key' autoload :ExtPubkey, 'bitcoin/ext_key' autoload :Builder, 'bitcoin/builder' @@ -39,6 +40,7 @@ module Bitcoin autoload :ContractHash, 'bitcoin/contracthash' + module Trezor autoload :Mnemonic, 'bitcoin/trezor/mnemonic' end @@ -286,11 +288,11 @@ def decode_target(target_bits) end def bitcoin_elliptic_curve - ::OpenSSL::PKey::EC.new("secp256k1") + Bitcoin::PKeyEC.new end def generate_key - key = bitcoin_elliptic_curve.generate_key + key = Bitcoin::PKeyEC.generate_key inspect_key( key ) end @@ -409,11 +411,13 @@ def verify_signature(hash, signature, public_key) def open_key(private_key, public_key=nil) key = bitcoin_elliptic_curve key.private_key = ::OpenSSL::BN.from_hex(private_key) - public_key = regenerate_public_key(private_key) unless public_key - key.public_key = ::OpenSSL::PKey::EC::Point.from_hex(key.group, public_key) key end + def group + OpenSSL::PKey::EC::Group.new('secp256k1') + end + def regenerate_public_key(private_key) OpenSSL_EC.regenerate_key(private_key)[1] end @@ -439,6 +443,7 @@ def verify_message(address, signature, message) return false unless valid_address?(address) return false unless signature return false unless signature.bytesize == 65 + hash = bitcoin_signed_message_hash(message) pubkey = OpenSSL_EC.recover_compact(hash, signature) pubkey_to_address(pubkey) == address if pubkey diff --git a/lib/bitcoin/ffi/openssl.rb b/lib/bitcoin/ffi/openssl.rb index d3abb316..7f4ff947 100644 --- a/lib/bitcoin/ffi/openssl.rb +++ b/lib/bitcoin/ffi/openssl.rb @@ -129,11 +129,23 @@ def self.regenerate_key(private_key) private_key_hex = private_key.unpack('H*')[0] group = OpenSSL::PKey::EC::Group.new('secp256k1') - key = OpenSSL::PKey::EC.new(group) - key.private_key = OpenSSL::BN.new(private_key_hex, 16) - key.public_key = group.generator.mul(key.private_key) - - priv_hex = key.private_key.to_bn.to_s(16).downcase.rjust(64, '0') + private_key_bn = OpenSSL::BN.new(private_key_hex, 16) + + # Generate public key point by multiplying generator with private key + public_key_point = group.generator.mul(private_key_bn) + + # Create ASN1 structure for EC key + asn1 = OpenSSL::ASN1::Sequence([ + OpenSSL::ASN1::Integer.new(1), + OpenSSL::ASN1::OctetString(private_key_bn.to_s(2)), + OpenSSL::ASN1::ObjectId('secp256k1', 0, :EXPLICIT), + OpenSSL::ASN1::BitString(public_key_point.to_octet_string(:uncompressed), 1, :EXPLICIT) + ]) + + key = OpenSSL::PKey::EC.new(asn1.to_der) + + # Verify the private key was generated correctly + priv_hex = key.private_key.to_s(16).downcase.rjust(64, '0') if priv_hex != private_key_hex raise 'regenerated wrong private_key, raise here before generating a faulty public_key too!' end diff --git a/lib/bitcoin/key.rb b/lib/bitcoin/key.rb index f8c6a855..79c34d92 100644 --- a/lib/bitcoin/key.rb +++ b/lib/bitcoin/key.rb @@ -48,7 +48,7 @@ def initialize(privkey = nil, pubkey = nil, opts={compressed: true}) # Generate new priv/pub key. def generate - @key.generate_key + @key = Bitcoin::PKeyEC.generate_key end # Get the private key (in hex). @@ -155,7 +155,7 @@ def self.recover_compact_signature_to_key(data, signature_base64) version = signature.unpack('C')[0] return nil if version < 27 or version > 34 - + compressed = (version >= 31) ? (version -= 4; true) : false hash = Bitcoin.bitcoin_signed_message_hash(data) @@ -183,7 +183,7 @@ def to_bip38(passphrase) addresshash = Digest::SHA256.digest( Digest::SHA256.digest( self.addr ) )[0...4] require 'scrypt' unless defined?(::SCrypt::Engine) - buf = SCrypt::Engine.__sc_crypt(passphrase, addresshash, 16384, 8, 8, 64) + buf = SCrypt::Engine.scrypt(passphrase, addresshash, 16384, 8, 8, 64) derivedhalf1, derivedhalf2 = buf[0...32], buf[32..-1] aes = proc{|k,a,b| @@ -212,7 +212,7 @@ def self.from_bip38(encrypted_privkey, passphrase) raise "Invalid checksum" unless Digest::SHA256.digest(Digest::SHA256.digest(version + flagbyte + addresshash + encryptedhalf1 + encryptedhalf2))[0...4] == checksum require 'scrypt' unless defined?(::SCrypt::Engine) - buf = SCrypt::Engine.__sc_crypt(passphrase, addresshash, 16384, 8, 8, 64) + buf = SCrypt::Engine.scrypt(passphrase, addresshash, 16384, 8, 8, 64) derivedhalf1, derivedhalf2 = buf[0...32], buf[32..-1] aes = proc{|k,a| diff --git a/lib/bitcoin/pkey_ec.rb b/lib/bitcoin/pkey_ec.rb new file mode 100644 index 00000000..0af85aac --- /dev/null +++ b/lib/bitcoin/pkey_ec.rb @@ -0,0 +1,67 @@ +module Bitcoin + class PKeyEC #< OpenSSL::PKey::EC + + CURVE = 'secp256k1' + + attr_reader :group, :private_key, :public_key + + + def private_key_hex; private_key.to_hex.rjust(64, '0'); end + def public_key_hex; public_key.to_hex.rjust(130, '0'); end + + def initialize + @group = OpenSSL::PKey::EC::Group.new(CURVE) + end + + def self.generate_key + OpenSSL::PKey::EC.generate(CURVE) + end + + def public_key=(public_key_bn) + @public_key = public_key_bn + end + + def private_key=(private_key_bn) + @public_key = restore_public_key(private_key_bn) + asn1 = OpenSSL::ASN1::Sequence( + [ + OpenSSL::ASN1::Integer.new(1), + OpenSSL::ASN1::OctetString(private_key_bn.to_s(2)), + OpenSSL::ASN1::ObjectId(CURVE, 0, :EXPLICIT), + OpenSSL::ASN1::BitString(@public_key.to_octet_string(:uncompressed), 1, :EXPLICIT) + ] + ) + @pk = OpenSSL::PKey::EC.new(asn1.to_der) + @private_key = @pk.private_key + end + + def dsa_sign_asn1(data) + @pk.dsa_sign_asn1(data) + end + + def dsa_verify_asn1(data, signature) + initialize_from_public_key unless @pk + @pk.dsa_verify_asn1(data, signature) + end + + def initialize_from_public_key + asn1 = OpenSSL::ASN1::Sequence.new( + [ + OpenSSL::ASN1::Sequence.new([ + OpenSSL::ASN1::ObjectId.new('id-ecPublicKey'), + OpenSSL::ASN1::ObjectId.new(@group.curve_name) + ]), + OpenSSL::ASN1::BitString.new(@public_key.to_octet_string(:uncompressed)) + ] + ) + @pk = OpenSSL::PKey::EC.new(asn1.to_der) + end + + private + + def restore_public_key(private_bn) + public_bn = group.generator.mul(private_bn).to_bn + public_bn = OpenSSL::PKey::EC::Point.new(@group, public_bn) + end + end +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4181bbae..9a07266e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -7,6 +7,7 @@ # Code coverage generation require 'simplecov' +require 'debug' SimpleCov.start do add_group('Bitcoin') do |file| ['bitcoin.rb', 'opcodes.rb', 'script.rb', 'key.rb'].include?( @@ -106,4 +107,4 @@ config.before(:each) do Bitcoin.network = :bitcoin end -end +end \ No newline at end of file diff --git a/spec/unit/bitcoin/bitcoin_spec.rb b/spec/unit/bitcoin/bitcoin_spec.rb index 34decd93..45c8315c 100644 --- a/spec/unit/bitcoin/bitcoin_spec.rb +++ b/spec/unit/bitcoin/bitcoin_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'pry' +require 'debug' require 'spec_helper' describe Bitcoin do @@ -973,14 +973,16 @@ describe 'bitcoin base58 test vectors' do # Port of Bitcoin Core test vectors. - # https://github.com/bitcoin/bitcoin/blob/595a7bab23bc21049526229054ea1fff1a29c0bf/src/test/base58_tests.cpp#L139 + # https://github.com/bitcoin/bitcoin/blob/master/src/test/base58_tests.cpp let(:valid_base58_keys) do - JSON.parse(fixtures_file('base58_keys_valid.json')) + path = File.join(File.dirname(__FILE__), '../../fixtures/base58_keys_valid.json') + JSON.parse(File.read(path)) end # Port of Bitcoin Core test vectors. - # https://github.com/bitcoin/bitcoin/blob/595a7bab23bc21049526229054ea1fff1a29c0bf/src/test/base58_tests.cpp#L179 + # https://github.com/bitcoin/bitcoin/blob/master/src/test/base58_tests.cpp let(:invalid_base58_keys) do - JSON.parse(fixtures_file('base58_keys_invalid.json')) + path = File.join(File.dirname(__FILE__), '../../fixtures/base58_keys_invalid.json') + JSON.parse(File.read(path)) end it 'passes the valid keys cases' do @@ -1014,8 +1016,7 @@ end it 'fails the invalid keys cases' do - test_cases = JSON.parse(fixtures_file('base58_keys_invalid.json')) - test_cases.each do |test_case| + invalid_base58_keys.each do |test_case| address = test_case[0] %i[bitcoin testnet3 regtest].each do |network_name| diff --git a/spec/unit/bitcoin/pkey_ec_spec.rb b/spec/unit/bitcoin/pkey_ec_spec.rb new file mode 100644 index 00000000..0a9be1d3 --- /dev/null +++ b/spec/unit/bitcoin/pkey_ec_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +module Bitcoin + describe PKeyEC do + let(:pkey) { PKeyEC } + describe '.generate_key' do + let(:generated_key) { Bitcoin::PKeyEC.generate_key } + + it 'generates a valid EC key' do + expect(generated_key).to be_a(OpenSSL::PKey::EC) + end + + it 'generates a key with private key component' do + expect(generated_key.private_key?).to be true + end + + it 'generates a key with public key component' do + expect(generated_key.public_key?).to be true + end + + it 'generates a key on the secp256k1 curve' do + expect(generated_key.group.curve_name).to eq('secp256k1') + end + + it 'generates unique keys on subsequent calls' do + key1 = pkey.generate_key + key2 = pkey.generate_key + expect(key1.private_key.to_s(16)).not_to eq(key2.private_key.to_s(16)) + end + end + + describe '#private_key' do + let(:key) { Bitcoin::PKeyEC.new } + + it 'sets the private key' do + key.private_key = OpenSSL::BN.new('0123456789abcdef', 16) + expect(key.private_key.to_s(16)).to eq('0123456789ABCDEF') + end + + it 'returns the public key' do + key.private_key = OpenSSL::BN.new('0123456789abcdef', 16) + expect(key.public_key_hex).to eq('041a1fd15fce078234aa292fc024178056bf006433c9b4bd208f59eb4c9efec95ba18af1fe46980989d3ff75bf9601121151ef46e2cfab8999408319ce8f3be725') + end + end + end +end \ No newline at end of file