From 60a370f66d19ca2bd433880e079675577f00ee94 Mon Sep 17 00:00:00 2001 From: Eric Scrivner Date: Sat, 28 Sep 2019 11:13:21 -0700 Subject: [PATCH 1/4] Replace OpenSSL With rbsecp25k1 This change replaces the current OpenSSL secp256k1 ECDSA with rbsecp256k1, a binding to libsecp256k1. It replaces all key generation, signing, and key recovery methods with their rbsecp256k1 equivalents. --- .gitignore | 5 +++ .travis.yml | 1 - eth.gemspec | 1 + lib/eth.rb | 2 ++ lib/eth/chains.rb | 53 +++++++++++++++++++++++++++++ lib/eth/key.rb | 79 +++++++++++++++++++++++++++++++++----------- lib/eth/tx.rb | 2 +- lib/eth/version.rb | 2 +- spec/eth/key_spec.rb | 8 +++-- 9 files changed, 127 insertions(+), 26 deletions(-) create mode 100644 lib/eth/chains.rb diff --git a/.gitignore b/.gitignore index 8efe630..5212b99 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ /spec/reports/ /tmp/ .ruby-version + +# Emacs +*~ +\#*\# +.\#* \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index ab8b076..531b160 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,6 @@ sudo: false dist: xenial language: ruby rvm: - - 2.2.0 - 2.3.0 - 2.4.0 - 2.5.3 diff --git a/eth.gemspec b/eth.gemspec index b4c28ff..383f722 100644 --- a/eth.gemspec +++ b/eth.gemspec @@ -22,6 +22,7 @@ Gem::Specification.new do |spec| spec.add_dependency 'digest-sha3-patched', '~> 1.1' spec.add_dependency 'ffi', '~> 1.0' spec.add_dependency 'money-tree', '~> 0.10.0' + spec.add_dependency 'rbsecp256k1', '~> 5.0.0' spec.add_dependency 'rlp', '~> 0.7.3' spec.add_dependency 'scrypt', '~> 3.0.6' diff --git a/lib/eth.rb b/lib/eth.rb index 74710d3..5f47b95 100644 --- a/lib/eth.rb +++ b/lib/eth.rb @@ -2,12 +2,14 @@ require 'ffi' require 'money-tree' require 'rlp' +require 'rbsecp256k1' module Eth BYTE_ZERO = "\x00".freeze UINT_MAX = 2**256 - 1 autoload :Address, 'eth/address' + autoload :Chains, 'eth/chains' autoload :Gas, 'eth/gas' autoload :Key, 'eth/key' autoload :OpenSsl, 'eth/open_ssl' diff --git a/lib/eth/chains.rb b/lib/eth/chains.rb new file mode 100644 index 0000000..dba42c1 --- /dev/null +++ b/lib/eth/chains.rb @@ -0,0 +1,53 @@ +module Eth + # Encapsulates utilities and constants for various Ethereum chains. + module Chains + # Chain IDs for various chains (from EIP-155) + MAINNET = 1 + MORDEN = 2 + ROPSTEN = 3 + RINKEBY = 4 + KOVAN = 42 + ETC_MAINNET = 61 + ETC_TESTNET = 62 + + # Indicates whether or not the given value represents a legacy chain v. + # + # @return [Boolean] true if the v represents a signature before the ETC + # fork, false if it does not. + def self.legacy_recovery_id?(v) + [27, 28].include?(v) + end + + # Convert a v value into an ECDSA recovery id. + # + # See EIP-155 for more information the computations done in this method: + # https://github.com/ethereum/EIPs/blob/master/EIPS/eip-155.md + # + # @param v [Integer] v value from a signature. + # @param chain_id [Integer] chain ID for the chain the signature was + # generated on. + # @return [Integer] the recovery id corresponding to the given v value. + # @raise [ArgumentError] if the given v value is invalid. + def self.to_recovery_id(v, chain_id) + # Handle the legacy network recovery ids + return v - 27 if legacy_recovery_id?(v) + + raise ArgumentError, "Invalid legacy v value #{v}." if chain_id.nil? + + if [(2 * chain_id + 35), (2 * chain_id + 36)].include?(v) + return v - 35 - 2 * chain_id + end + + raise ArgumentError, "Invalid v value for chain #{chain_id}. Invalid chain_id?" + end + + # Converts a recovery ID into the expected v value. + # + # @param recovery_id [Integer] signature recovery id (should be 0 or 1). + # @param chain_id [Integer] Unique ID of the Ethereum chain. + # @return [Integer] the v value for the recovery id. + def self.to_v(recovery_id, chain_id) + 2 * chain_id + 35 + recovery_id + end + end +end diff --git a/lib/eth/key.rb b/lib/eth/key.rb index d9e8da7..abedf1c 100644 --- a/lib/eth/key.rb +++ b/lib/eth/key.rb @@ -1,3 +1,5 @@ +require 'rbsecp256k1' + module Eth class Key autoload :Decrypter, 'eth/key/decrypter' @@ -16,26 +18,47 @@ def self.decrypt(data, password) new priv: priv end - def self.personal_recover(message, signature) - bin_signature = Utils.hex_to_bin(signature).bytes.rotate(-1).pack('c*') - OpenSsl.recover_compact(Utils.keccak256(Utils.prefix_message(message)), bin_signature) + def self.recover_public_key(hash, signature, chain_id: nil) + context = ::Secp256k1::Context.new + v = signature.unpack('C').first + recovery_id = Eth::Chains.to_recovery_id(v, chain_id) + recoverable_signature = context.recoverable_signature_from_compact( + signature[1..-1], recovery_id + ) + public_key_bin = recoverable_signature.recover_public_key(hash).uncompressed + Utils.bin_to_hex(public_key_bin) + rescue ::Secp256k1::DeserializationError + false + end + + def self.personal_recover(message, signature, chain_id: nil) + hash = Utils.keccak256(Utils.prefix_message(message)) + bin_sig = ::Secp256k1::Util.hex_to_bin(signature).bytes.rotate(-1).pack('c*') + recover_public_key(hash, bin_sig, chain_id: chain_id) end def initialize(priv: nil) - @private_key = MoneyTree::PrivateKey.new key: priv - @public_key = MoneyTree::PublicKey.new private_key, compressed: false + @context = ::Secp256k1::Context.new + key_pair = + if priv.nil? + @context.generate_key_pair + else + @context.key_pair_from_private_key(::Secp256k1::Util.hex_to_bin(priv)) + end + @private_key = key_pair.private_key + @public_key = key_pair.public_key end def private_hex - private_key.to_hex + ::Secp256k1::Util.bin_to_hex(private_key.data) end def public_bytes - public_key.to_bytes + public_key.data end def public_hex - public_key.to_hex + ::Secp256k1::Util.bin_to_hex(public_key.uncompressed) end def address @@ -47,33 +70,49 @@ def sign(message) sign_hash message_hash(message) end - def sign_hash(hash) - loop do - signature = OpenSsl.sign_compact hash, private_hex, public_hex - return signature if valid_s? signature + def sign_hash(hash, chain_id: nil) + if chain_id.nil? + sign_legacy(private_key, hash) + else + signature, recovery_id = @context.sign_recoverable(private_key, hash).compact + result = signature.bytes + result.unshift(Eth::Chains.to_v(recovery_id, chain_id)) + result.pack('c*') end end - def verify_signature(message, signature) + # Produces signature with legacy (pre-ETC fork) v values. + # + # @param private_key [Secp256k1::PrivateKey] signing key. + # @param hash [String] hash to be signed. + # @return [String] binary signature data. + def sign_legacy(private_key, hash) + signature, recovery_id = @context.sign_recoverable(private_key, hash).compact + result = signature.bytes + result.unshift(Eth.v_base + recovery_id) + result.pack('c*') + end + + def verify_signature(message, signature, chain_id: nil) hash = message_hash(message) - public_hex == OpenSsl.recover_compact(hash, signature) + v = signature.unpack('C')[0] + recovery_id = Eth::Chains.to_recovery_id(v, chain_id) + recoverable_signature = @context.recoverable_signature_from_compact( + signature[1..-1], recovery_id + ) + public_key_bin = recoverable_signature.recover_public_key(hash).uncompressed + public_hex == ::Secp256k1::Util.bin_to_hex(public_key_bin) end def personal_sign(message) Utils.bin_to_hex(sign(Utils.prefix_message(message)).bytes.rotate(1).pack('c*')) end - private def message_hash(message) Utils.keccak256 message end - def valid_s?(signature) - s_value = Utils.v_r_s_for(signature).last - s_value <= Secp256k1::N/2 && s_value != 0 - end - end end diff --git a/lib/eth/tx.rb b/lib/eth/tx.rb index b44d986..371f6dd 100644 --- a/lib/eth/tx.rb +++ b/lib/eth/tx.rb @@ -79,7 +79,7 @@ def to_h def from if ecdsa_signature - public_key = OpenSsl.recover_compact(signature_hash, ecdsa_signature) + public_key = Key.recover_public_key(signature_hash, ecdsa_signature) Utils.public_key_to_address(public_key) if public_key end end diff --git a/lib/eth/version.rb b/lib/eth/version.rb index 8337aac..9ffca16 100644 --- a/lib/eth/version.rb +++ b/lib/eth/version.rb @@ -1,3 +1,3 @@ module Eth - VERSION = "0.4.10" + VERSION = "0.5.0" end diff --git a/spec/eth/key_spec.rb b/spec/eth/key_spec.rb index 6b8b321..30fbb41 100644 --- a/spec/eth/key_spec.rb +++ b/spec/eth/key_spec.rb @@ -74,11 +74,13 @@ end end - context "when the signature does not match any public key" do + context "when the signature is invalid" do let(:signature) { hex_to_bin "1b21a66b" } - it "signs a message so that the public key is recoverable" do - expect(key.verify_signature message, signature).to be_falsy + it "raises an error" do + expect do + key.verify_signature(message, signature) + end.to raise_error(::Secp256k1::Error) end end end From 57b52d832c9bf6d068b92afa5dd115e56d7a01c4 Mon Sep 17 00:00:00 2001 From: Eric Scrivner Date: Sat, 28 Sep 2019 14:21:54 -0700 Subject: [PATCH 2/4] Remove OpenSSL and Secp256k1 Remove the OpenSSL and Secp256k1 modules as they are replaced by rbsecp256k1. We also no longer need to check that signatures are in lower-s form as this is done by libsecp256k1. --- lib/eth.rb | 2 - lib/eth/open_ssl.rb | 256 ------------------------------------------- lib/eth/secp256k1.rb | 7 -- spec/eth/key_spec.rb | 2 - 4 files changed, 267 deletions(-) delete mode 100644 lib/eth/open_ssl.rb delete mode 100644 lib/eth/secp256k1.rb diff --git a/lib/eth.rb b/lib/eth.rb index 5f47b95..f7ab332 100644 --- a/lib/eth.rb +++ b/lib/eth.rb @@ -12,8 +12,6 @@ module Eth autoload :Chains, 'eth/chains' autoload :Gas, 'eth/gas' autoload :Key, 'eth/key' - autoload :OpenSsl, 'eth/open_ssl' - autoload :Secp256k1, 'eth/secp256k1' autoload :Sedes, 'eth/sedes' autoload :Tx, 'eth/tx' autoload :Utils, 'eth/utils' diff --git a/lib/eth/open_ssl.rb b/lib/eth/open_ssl.rb deleted file mode 100644 index 1c9713d..0000000 --- a/lib/eth/open_ssl.rb +++ /dev/null @@ -1,256 +0,0 @@ -# originally lifted from https://github.com/lian/bitcoin-ruby -# thanks to everyone there for figuring this out - -module Eth - class OpenSsl - extend FFI::Library - - if FFI::Platform.windows? - ffi_lib 'libeay32', 'ssleay32' - else - ffi_lib [ - 'libssl.so.1.1.0', 'libssl.so.1.1', - 'libssl.so.1.0.0', 'libssl.so.10', - 'ssl' - ] - end - - NID_secp256k1 = 714 - POINT_CONVERSION_COMPRESSED = 2 - POINT_CONVERSION_UNCOMPRESSED = 4 - - # OpenSSL 1.1.0 version as a numerical version value as defined in: - # https://www.openssl.org/docs/man1.1.0/man3/OpenSSL_version.html - VERSION_1_1_0_NUM = 0x10100000 - - # OpenSSL 1.1.0 engine constants, taken from: - # https://github.com/openssl/openssl/blob/2be8c56a39b0ec2ec5af6ceaf729df154d784a43/include/openssl/crypto.h - OPENSSL_INIT_ENGINE_RDRAND = 0x00000200 - OPENSSL_INIT_ENGINE_DYNAMIC = 0x00000400 - OPENSSL_INIT_ENGINE_CRYPTODEV = 0x00001000 - OPENSSL_INIT_ENGINE_CAPI = 0x00002000 - OPENSSL_INIT_ENGINE_PADLOCK = 0x00004000 - OPENSSL_INIT_ENGINE_ALL_BUILTIN = ( - OPENSSL_INIT_ENGINE_RDRAND | - OPENSSL_INIT_ENGINE_DYNAMIC | - OPENSSL_INIT_ENGINE_CRYPTODEV | - OPENSSL_INIT_ENGINE_CAPI | - OPENSSL_INIT_ENGINE_PADLOCK - ) - - # OpenSSL 1.1.0 load strings constant, taken from: - # https://github.com/openssl/openssl/blob/c162c126be342b8cd97996346598ecf7db56130f/include/openssl/ssl.h - OPENSSL_INIT_LOAD_SSL_STRINGS = 0x00200000 - - # This is the very first function we need to use to determine what version - # of OpenSSL we are interacting with. - begin - attach_function :OpenSSL_version_num, [], :ulong - rescue FFI::NotFoundError - attach_function :SSLeay, [], :long - end - - # Returns the version of SSL present. - # - # @return [Integer] version number as an integer. - def self.version - if self.respond_to?(:OpenSSL_version_num) - OpenSSL_version_num() - else - SSLeay() - end - end - - if version >= VERSION_1_1_0_NUM - # Initialization procedure for the library was changed in OpenSSL 1.1.0 - attach_function :OPENSSL_init_ssl, [:uint64, :pointer], :int - else - attach_function :SSL_library_init, [], :int - attach_function :ERR_load_crypto_strings, [], :void - attach_function :SSL_load_error_strings, [], :void - end - - attach_function :RAND_poll, [], :int - attach_function :BN_CTX_free, [:pointer], :int - attach_function :BN_CTX_new, [], :pointer - attach_function :BN_add, [:pointer, :pointer, :pointer], :int - attach_function :BN_bin2bn, [:pointer, :int, :pointer], :pointer - attach_function :BN_bn2bin, [:pointer, :pointer], :int - attach_function :BN_cmp, [:pointer, :pointer], :int - attach_function :BN_dup, [:pointer], :pointer - attach_function :BN_free, [:pointer], :int - attach_function :BN_mod_inverse, [:pointer, :pointer, :pointer, :pointer], :pointer - attach_function :BN_mod_mul, [:pointer, :pointer, :pointer, :pointer, :pointer], :int - attach_function :BN_mod_sub, [:pointer, :pointer, :pointer, :pointer, :pointer], :int - attach_function :BN_mul_word, [:pointer, :int], :int - attach_function :BN_new, [], :pointer - attach_function :BN_num_bits, [:pointer], :int - attach_function :BN_rshift, [:pointer, :pointer, :int], :int - attach_function :BN_set_word, [:pointer, :int], :int - attach_function :ECDSA_SIG_free, [:pointer], :void - attach_function :ECDSA_do_sign, [:pointer, :uint, :pointer], :pointer - attach_function :EC_GROUP_get_curve_GFp, [:pointer, :pointer, :pointer, :pointer, :pointer], :int - attach_function :EC_GROUP_get_degree, [:pointer], :int - attach_function :EC_GROUP_get_order, [:pointer, :pointer, :pointer], :int - attach_function :EC_KEY_free, [:pointer], :int - attach_function :EC_KEY_get0_group, [:pointer], :pointer - attach_function :EC_KEY_new_by_curve_name, [:int], :pointer - attach_function :EC_KEY_set_conv_form, [:pointer, :int], :void - attach_function :EC_KEY_set_private_key, [:pointer, :pointer], :int - attach_function :EC_KEY_set_public_key, [:pointer, :pointer], :int - attach_function :EC_POINT_free, [:pointer], :int - attach_function :EC_POINT_mul, [:pointer, :pointer, :pointer, :pointer, :pointer, :pointer], :int - attach_function :EC_POINT_new, [:pointer], :pointer - attach_function :EC_POINT_set_compressed_coordinates_GFp, [:pointer, :pointer, :pointer, :int, :pointer], :int - attach_function :i2o_ECPublicKey, [:pointer, :pointer], :uint - - class << self - def BN_num_bytes(ptr) - (BN_num_bits(ptr) + 7) / 8 - end - - def sign_compact(hash, private_key, public_key_hex) - private_key = [private_key].pack("H*") if private_key.bytesize >= 64 - pubkey_compressed = false - - init_ffi_ssl - eckey = EC_KEY_new_by_curve_name(NID_secp256k1) - priv_key = BN_bin2bn(private_key, private_key.bytesize, BN_new()) - - group, order, ctx = EC_KEY_get0_group(eckey), BN_new(), BN_CTX_new() - EC_GROUP_get_order(group, order, ctx) - - pub_key = EC_POINT_new(group) - EC_POINT_mul(group, pub_key, priv_key, nil, nil, ctx) - EC_KEY_set_private_key(eckey, priv_key) - EC_KEY_set_public_key(eckey, pub_key) - - signature = ECDSA_do_sign(hash, hash.bytesize, eckey) - - BN_free(order) - BN_CTX_free(ctx) - EC_POINT_free(pub_key) - BN_free(priv_key) - EC_KEY_free(eckey) - - buf, rec_id, head = FFI::MemoryPointer.new(:uint8, 32), nil, nil - r, s = signature.get_array_of_pointer(0, 2).map{|i| BN_bn2bin(i, buf); buf.read_string(BN_num_bytes(i)).rjust(32, "\x00") } - - if signature.get_array_of_pointer(0, 2).all?{|i| BN_num_bits(i) <= 256 } - 4.times{|i| - head = [ Eth.v_base + i ].pack("C") - if public_key_hex == recover_public_key_from_signature(hash, [head, r, s].join, i, pubkey_compressed) - rec_id = i; break - end - } - end - - ECDSA_SIG_free(signature) - - [ head, [r,s] ].join if rec_id - end - - def recover_public_key_from_signature(message_hash, signature, rec_id, is_compressed) - return nil if rec_id < 0 or signature.bytesize != 65 - init_ffi_ssl - - signature = FFI::MemoryPointer.from_string(signature) - r = BN_bin2bn(signature[1], 32, BN_new()) - s = BN_bin2bn(signature[33], 32, BN_new()) - - _n, i = 0, rec_id / 2 - eckey = EC_KEY_new_by_curve_name(NID_secp256k1) - - EC_KEY_set_conv_form(eckey, POINT_CONVERSION_COMPRESSED) if is_compressed - - group = EC_KEY_get0_group(eckey) - order = BN_new() - EC_GROUP_get_order(group, order, nil) - x = BN_dup(order) - BN_mul_word(x, i) - BN_add(x, x, r) - - field = BN_new() - EC_GROUP_get_curve_GFp(group, field, nil, nil, nil) - - if BN_cmp(x, field) >= 0 - bn_free_each r, s, order, x, field - EC_KEY_free(eckey) - return nil - end - - big_r = EC_POINT_new(group) - EC_POINT_set_compressed_coordinates_GFp(group, big_r, x, rec_id % 2, nil) - - big_q = EC_POINT_new(group) - n = EC_GROUP_get_degree(group) - e = BN_bin2bn(message_hash, message_hash.bytesize, BN_new()) - BN_rshift(e, e, 8 - (n & 7)) if 8 * message_hash.bytesize > n - - ctx = BN_CTX_new() - zero, rr, sor, eor = BN_new(), BN_new(), BN_new(), BN_new() - BN_set_word(zero, 0) - BN_mod_sub(e, zero, e, order, ctx) - BN_mod_inverse(rr, r, order, ctx) - BN_mod_mul(sor, s, rr, order, ctx) - BN_mod_mul(eor, e, rr, order, ctx) - EC_POINT_mul(group, big_q, eor, big_r, sor, ctx) - EC_KEY_set_public_key(eckey, big_q) - BN_CTX_free(ctx) - - bn_free_each r, s, order, x, field, e, zero, rr, sor, eor - [big_r, big_q].each{|j| EC_POINT_free(j) } - - recover_public_hex eckey - end - - def recover_compact(hash, signature) - return false if signature.bytesize != 65 - - version = signature.unpack('C')[0] - v_base = Eth.replayable_v?(version) ? Eth.replayable_chain_id : Eth.v_base - return false if version < v_base - - recover_public_key_from_signature(hash, signature, (version - v_base), false) - end - - def init_ffi_ssl - return if @ssl_loaded - if version >= VERSION_1_1_0_NUM - OPENSSL_init_ssl( - OPENSSL_INIT_LOAD_SSL_STRINGS | OPENSSL_INIT_ENGINE_ALL_BUILTIN, - nil - ) - else - SSL_library_init() - ERR_load_crypto_strings() - SSL_load_error_strings() - end - - RAND_poll() - @ssl_loaded = true - end - - - private - - def bn_free_each(*list) - list.each{|j| BN_free(j) } - end - - def recover_public_hex(eckey) - length = i2o_ECPublicKey(eckey, nil) - buf = FFI::MemoryPointer.new(:uint8, length) - ptr = FFI::MemoryPointer.new(:pointer).put_pointer(0, buf) - pub_hex = if i2o_ECPublicKey(eckey, ptr) == length - buf.read_string(length).unpack("H*")[0] - end - - EC_KEY_free(eckey) - - pub_hex - end - end - - end -end diff --git a/lib/eth/secp256k1.rb b/lib/eth/secp256k1.rb deleted file mode 100644 index d064f72..0000000 --- a/lib/eth/secp256k1.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Eth - class Secp256k1 - - N = 115792089237316195423570985008687907852837564279074904382605163141518161494337 - - end -end diff --git a/spec/eth/key_spec.rb b/spec/eth/key_spec.rb index 30fbb41..66c537c 100644 --- a/spec/eth/key_spec.rb +++ b/spec/eth/key_spec.rb @@ -27,8 +27,6 @@ 10.times do signature = key.sign message expect(key.verify_signature message, signature).to be_truthy - s_value = Eth::Utils.v_r_s_for(signature).last - expect(s_value).to be < (Eth::Secp256k1::N/2) end end end From 0134ae4b1b38e1d29aeb33d031d56156dbb33136 Mon Sep 17 00:00:00 2001 From: Eric Scrivner Date: Sat, 28 Sep 2019 15:20:55 -0700 Subject: [PATCH 3/4] Factor Out Recoverable Signature Functionality Factor the recoverable signature functionality out into two separate classes. `PersonalMessage` which represents a message to be recoverably signed, typically to prove key ownership. `RecoverableSignature` which represents a recoverable signature and provides methods for easily recovering the public key from it. --- lib/eth.rb | 2 ++ lib/eth/key.rb | 56 +++++++++++++++++++------------- lib/eth/personal_message.rb | 52 +++++++++++++++++++++++++++++ lib/eth/recoverable_signature.rb | 48 +++++++++++++++++++++++++++ lib/eth/utils.rb | 7 ---- spec/eth/chains_spec.rb | 43 ++++++++++++++++++++++++ 6 files changed, 179 insertions(+), 29 deletions(-) create mode 100644 lib/eth/personal_message.rb create mode 100644 lib/eth/recoverable_signature.rb create mode 100644 spec/eth/chains_spec.rb diff --git a/lib/eth.rb b/lib/eth.rb index f7ab332..dab6a7a 100644 --- a/lib/eth.rb +++ b/lib/eth.rb @@ -12,6 +12,8 @@ module Eth autoload :Chains, 'eth/chains' autoload :Gas, 'eth/gas' autoload :Key, 'eth/key' + autoload :PersonalMessage, 'eth/personal_message' + autoload :RecoverableSignature, 'eth/recoverable_signature' autoload :Sedes, 'eth/sedes' autoload :Tx, 'eth/tx' autoload :Utils, 'eth/utils' diff --git a/lib/eth/key.rb b/lib/eth/key.rb index abedf1c..b992638 100644 --- a/lib/eth/key.rb +++ b/lib/eth/key.rb @@ -1,5 +1,3 @@ -require 'rbsecp256k1' - module Eth class Key autoload :Decrypter, 'eth/key/decrypter' @@ -18,8 +16,8 @@ def self.decrypt(data, password) new priv: priv end - def self.recover_public_key(hash, signature, chain_id: nil) - context = ::Secp256k1::Context.new + def self.recover_public_key(hash, signature, chain_id = nil) + context = Secp256k1::Context.new v = signature.unpack('C').first recovery_id = Eth::Chains.to_recovery_id(v, chain_id) recoverable_signature = context.recoverable_signature_from_compact( @@ -27,30 +25,30 @@ def self.recover_public_key(hash, signature, chain_id: nil) ) public_key_bin = recoverable_signature.recover_public_key(hash).uncompressed Utils.bin_to_hex(public_key_bin) - rescue ::Secp256k1::DeserializationError + rescue Secp256k1::DeserializationError false end - def self.personal_recover(message, signature, chain_id: nil) - hash = Utils.keccak256(Utils.prefix_message(message)) - bin_sig = ::Secp256k1::Util.hex_to_bin(signature).bytes.rotate(-1).pack('c*') + def self.personal_recover(message, signature, chain_id = nil) + hash = PersonalMessage.new(message).hash + bin_sig = Utils.hex_to_bin(signature).bytes.rotate(-1).pack('c*') recover_public_key(hash, bin_sig, chain_id: chain_id) end def initialize(priv: nil) - @context = ::Secp256k1::Context.new + @context = Secp256k1::Context.new key_pair = if priv.nil? @context.generate_key_pair else - @context.key_pair_from_private_key(::Secp256k1::Util.hex_to_bin(priv)) + @context.key_pair_from_private_key(Utils.hex_to_bin(priv)) end @private_key = key_pair.private_key @public_key = key_pair.public_key end def private_hex - ::Secp256k1::Util.bin_to_hex(private_key.data) + Utils.bin_to_hex(private_key.data) end def public_bytes @@ -58,7 +56,7 @@ def public_bytes end def public_hex - ::Secp256k1::Util.bin_to_hex(public_key.uncompressed) + Utils.bin_to_hex(public_key.uncompressed) end def address @@ -70,42 +68,57 @@ def sign(message) sign_hash message_hash(message) end - def sign_hash(hash, chain_id: nil) + # Sign a data hash returning signature. + # + # @param hash [String] Keccak256 hash as byte string. + # @param chain_id [Integer] (Optional) ID of the chain message or + # transaction belongs to. + # @return [String] Recoverable signature as byte string + def sign_hash(hash, chain_id = nil) if chain_id.nil? sign_legacy(private_key, hash) else - signature, recovery_id = @context.sign_recoverable(private_key, hash).compact + signature, recovery_id = + @context.sign_recoverable(private_key, hash).compact result = signature.bytes result.unshift(Eth::Chains.to_v(recovery_id, chain_id)) result.pack('c*') end end - # Produces signature with legacy (pre-ETC fork) v values. + # Produces signature with legacy v values. # # @param private_key [Secp256k1::PrivateKey] signing key. # @param hash [String] hash to be signed. # @return [String] binary signature data. def sign_legacy(private_key, hash) - signature, recovery_id = @context.sign_recoverable(private_key, hash).compact + signature, recovery_id = + @context.sign_recoverable(private_key, hash).compact result = signature.bytes result.unshift(Eth.v_base + recovery_id) result.pack('c*') end - def verify_signature(message, signature, chain_id: nil) + def verify_signature(message, signature, chain_id = nil) hash = message_hash(message) v = signature.unpack('C')[0] recovery_id = Eth::Chains.to_recovery_id(v, chain_id) recoverable_signature = @context.recoverable_signature_from_compact( signature[1..-1], recovery_id ) - public_key_bin = recoverable_signature.recover_public_key(hash).uncompressed - public_hex == ::Secp256k1::Util.bin_to_hex(public_key_bin) + public_key_bin = + recoverable_signature.recover_public_key(hash).uncompressed + public_hex == Utils.bin_to_hex(public_key_bin) end - def personal_sign(message) - Utils.bin_to_hex(sign(Utils.prefix_message(message)).bytes.rotate(1).pack('c*')) + def personal_sign(message, chain_id = nil) + signature = + if chain_id + PersonalMessage.new(message).sign(private_key, nil) + else + PersonalMessage.new(message).sign_legacy(private_key) + end + Secp256k1::Util.bin_to_hex(signature) end private @@ -113,6 +126,5 @@ def personal_sign(message) def message_hash(message) Utils.keccak256 message end - end end diff --git a/lib/eth/personal_message.rb b/lib/eth/personal_message.rb new file mode 100644 index 0000000..d9c9a9a --- /dev/null +++ b/lib/eth/personal_message.rb @@ -0,0 +1,52 @@ +module Eth + # Represents a personal message that can be signed with a private key. + class PersonalMessage + # Default constructor. + # + # @param message [String] personal message to be signed or verified. + def initialize(message) + @message = message + end + + def prefixed_message + # Prepend the expected web3.eth.sign message prefix + "\x19Ethereum Signed Message:\n#{@message.length}#{@message}" + end + + # Signs a personal message with the given private key. + # + # @param private_key [Secp256k1::PrivateKey] key to use for signing. + # @param chain_id [Integer] unique identifier for chain. + # @return [String] binary signature data including recovery id v at end. + def sign(private_key, chain_id) + ctx = Secp256k1::Context.new + signature, recovery_id = ctx.sign_recoverable(private_key, hash).compact + result = signature.bytes + result = result << Chains.to_v(recovery_id, chain_id) + result.pack('c*') + end + + # Produce a signature with legacy v values. + # + # @param private_key [Secp256k1::PrivateKey] key to use for signing. + # @return [String] binary signature data including legacy recovery id v at + # end. + def sign_legacy(private_key) + ctx = Secp256k1::Context.new + signature, recovery_id = ctx.sign_recoverable(private_key, hash).compact + result = signature.bytes + result = result << (27 + recovery_id) + result.pack('c*') + end + + # Returns the keccak256 hash of the message. + # + # Applies the expected prefix for personal messages signed with Ethereum + # keys. + # + # @return [String] binary string hash of the given data. + def hash + Utils.keccak256(prefixed_message) + end + end +end diff --git a/lib/eth/recoverable_signature.rb b/lib/eth/recoverable_signature.rb new file mode 100644 index 0000000..dc13e02 --- /dev/null +++ b/lib/eth/recoverable_signature.rb @@ -0,0 +1,48 @@ +module Eth + # Represents an Ethereum signature where the public key of signer is + # recoverable. + class RecoverableSignature + # Initialize recoverable signature. + # + # @param signature [String] Hex formatted signature + # @param chain_id [Integer] (Optional) chain ID used for deriving recovery + # id. + # @raise [ArgumentError] if signature is the wrong length. + # @raise [RuntimeError] if v value derived from signature is invalid. + def initialize(signature, chain_id = nil) + # Move the last byte containing the v value to the front. + rotated_signature = Utils.hex_to_bin(signature).bytes.rotate(-1) + + if rotated_signature.length != 65 + raise ArgumentError, 'invalid signature, must be 65 bytes in length' + end + + @v = rotated_signature[0] + + if chain_id && @v < chain_id + raise "invalid signature v '#{@v}' is not less than #{@chain_id}." + end + + @signature = rotated_signature[1..-1].pack('c*') + @chain_id = chain_id + end + + # Recover public key for this recoverable signature. + # + # @param message [PersonalMessage] The message to verify the signature + # against. + # @return [String] public key address corresponding to the public key + # recovered. + def recover_public_key(message) + ctx = Secp256k1::Context.new + recovery_id = Chains.to_recovery_id(@v, @chain_id) + + recoverable_signature = ctx.recoverable_signature_from_compact( + @signature, recovery_id + ) + public_key_bin = + recoverable_signature.recover_public_key(message.hash).uncompressed + public_key_to_address(public_key_bin) + end + end +end diff --git a/lib/eth/utils.rb b/lib/eth/utils.rb index ece7369..826e23c 100644 --- a/lib/eth/utils.rb +++ b/lib/eth/utils.rb @@ -51,10 +51,6 @@ def bin_to_prefixed_hex(binary) prefix_hex bin_to_hex(binary) end - def prefix_message(message) - "\x19Ethereum Signed Message:\n#{message.length}#{message}" - end - def public_key_to_address(hex) bytes = hex_to_bin(hex) address_bytes = Utils.keccak256(bytes[1..-1])[-20..-1] @@ -109,8 +105,6 @@ def format_address(address) Address.new(address).checksummed end - - private def lpad(x, symbol, l) @@ -125,6 +119,5 @@ def encode_int(n) int_to_base256 n end - end end diff --git a/spec/eth/chains_spec.rb b/spec/eth/chains_spec.rb new file mode 100644 index 0000000..9dbf2ed --- /dev/null +++ b/spec/eth/chains_spec.rb @@ -0,0 +1,43 @@ +describe Eth::Chains do + describe '.legacy_recover_id?' do + it 'is true for legacy versions' do + expect(described_class.legacy_recovery_id?(27)).to be true + expect(described_class.legacy_recovery_id?(28)).to be true + end + + it 'is false for non-legacy versions' do + expect(described_class.legacy_recovery_id?(29)).to be false + end + end + + describe '.to_recovery_id' do + it 'correctly handles legacy versions' do + expect(described_class.to_recovery_id(27, nil)).to eq(0) + expect(described_class.to_recovery_id(28, nil)).to eq(1) + end + + it 'raises an error if invalid legacy version is given without chain_id' do + expect do + expect(described_class.to_recovery_id(29, nil)) + end.to raise_error(ArgumentError, 'Invalid legacy v value 29.') + end + + it 'correctly handles non-legacy versions' do + expect(described_class.to_recovery_id(37, Eth::Chains::MAINNET)).to eq(0) + expect(described_class.to_recovery_id(38, Eth::Chains::MAINNET)).to eq(1) + end + + it 'raises an error if an invalid non-legacy version is given' do + expect do + described_class.to_recovery_id(29, Eth::Chains::MAINNET) + end.to raise_error(ArgumentError, 'Invalid v value for chain 1. Invalid chain_id?') + end + end + + describe '.to_v' do + it 'returns the correct version' do + expect(described_class.to_v(0, Eth::Chains::MAINNET)).to eq(37) + expect(described_class.to_v(1, Eth::Chains::MAINNET)).to eq(38) + end + end +end From 1bb5376f41712b5107a7249c7c6039ce260265ce Mon Sep 17 00:00:00 2001 From: Eric Scrivner Date: Sat, 28 Sep 2019 15:24:07 -0700 Subject: [PATCH 4/4] Remove TODO From README Remove TODO around libsecp256k1 signing from the README as this is now supported. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 393abdc..355c196 100644 --- a/README.md +++ b/README.md @@ -142,4 +142,3 @@ The gem is available as open source under the terms of the [MIT License](http:// * Better test suite. * Expose API for HD keys. -* Support signing with [libsecp256k1](https://github.com/bitcoin-core/secp256k1).