From a5ec2d83255b6f7a379e2f3e93a2204e0909cfc0 Mon Sep 17 00:00:00 2001 From: Timur Ramazanov Date: Sat, 10 Dec 2022 13:41:32 +0300 Subject: [PATCH] chore(sep-10): refactor SEP implementation --- base/lib/stellar/transaction_envelope.rb | 8 + .../lib/stellar/transaction_envelope_spec.rb | 20 + ecosystem/lib/stellar-ecosystem.rb | 4 + ecosystem/lib/stellar/sep10/challenge.rb | 120 ++++- .../lib/stellar/sep10/challenge_tx_builder.rb | 3 +- ecosystem/lib/stellar/sep10/server.rb | 111 ++++- ecosystem/spec/lib/stellar/sep10_v2_spec.rb | 446 ++++++++++++++++++ ecosystem/spec/spec_helper.rb | 11 +- ecosystem/spec/stellar/ecosystem_spec.rb | 11 - ecosystem/stellar-ecosystem.gemspec | 48 +- sdk/examples/07_sep10.rb | 2 +- sdk/lib/stellar/sep10.rb | 2 +- 12 files changed, 720 insertions(+), 66 deletions(-) create mode 100644 ecosystem/spec/lib/stellar/sep10_v2_spec.rb delete mode 100644 ecosystem/spec/stellar/ecosystem_spec.rb diff --git a/base/lib/stellar/transaction_envelope.rb b/base/lib/stellar/transaction_envelope.rb index 3b013e26..81553a35 100644 --- a/base/lib/stellar/transaction_envelope.rb +++ b/base/lib/stellar/transaction_envelope.rb @@ -26,6 +26,14 @@ def signed_correctly?(*key_pairs) end end + def signed_by?(keypair) + signatures.any? do |sig| + next if sig.hint != keypair.signature_hint + + keypair.verify(sig.signature, tx.hash) + end + end + def merge(other) merged_tx = tx.merge(other.tx) merged_tx.signatures = [signatures, other.signatures] diff --git a/base/spec/lib/stellar/transaction_envelope_spec.rb b/base/spec/lib/stellar/transaction_envelope_spec.rb index 8451c844..303c2a4c 100644 --- a/base/spec/lib/stellar/transaction_envelope_spec.rb +++ b/base/spec/lib/stellar/transaction_envelope_spec.rb @@ -88,4 +88,24 @@ subject { envelope.hash } it { is_expected.to eq(Digest::SHA256.digest(envelope.tx.signature_base)) } end + + describe "#signed_by?" do + let(:keypair) { KeyPair() } + + subject(:signed_by) do + envelope.signed_by?(keypair) + end + + context 'when envelope is signed by keypair' do + let(:signers) { [keypair] } + + it { is_expected.to be_truthy } + end + + context 'when envelope is not signed by keypair' do + let(:signers) { [] } + + it { is_expected.to be_falsey } + end + end end diff --git a/ecosystem/lib/stellar-ecosystem.rb b/ecosystem/lib/stellar-ecosystem.rb index b2a0f881..4714acc8 100644 --- a/ecosystem/lib/stellar-ecosystem.rb +++ b/ecosystem/lib/stellar-ecosystem.rb @@ -7,3 +7,7 @@ module Ecosystem VERSION = ::Stellar::VERSION end end + +require_relative "./stellar/sep10/challenge" +require_relative "./stellar/sep10/challenge_tx_builder" +require_relative "./stellar/sep10/server" diff --git a/ecosystem/lib/stellar/sep10/challenge.rb b/ecosystem/lib/stellar/sep10/challenge.rb index 24c21131..8ba592c9 100644 --- a/ecosystem/lib/stellar/sep10/challenge.rb +++ b/ecosystem/lib/stellar/sep10/challenge.rb @@ -1,71 +1,147 @@ module Stellar module Ecosystem module SEP10 + class InvalidChallengeError < StandardError; end + class Challenge + # We use a small grace period for the challenge transaction time bounds + # to compensate possible clock drift on client's machine + GRACE_PERIOD = 5.minutes + def self.build(server:, client:, domain: nil, timeout: 300, **options) tx = ChallengeTxBuilder.build( server: server, client: client, domain: domain, timeout: timeout, - options + **options ) - new(tx: tx) + new(envelope: tx.to_envelope(server), server: server) end - def self.read_xdr(xdr) + def self.read_xdr(xdr, server:) envelope = Stellar::TransactionEnvelope.from_xdr(xdr, "base64") - new(tx: envelope.tx) + new(envelope: envelope, server: server) + end + + def initialize(envelope:, server:) + @envelope = envelope + @tx = envelope.tx + @server = server end def to_xdr - tx.to_envelope(server).to_xdr(:base64) + @envelope.to_xdr(:base64) end - def validate!(server_keypair:, **options) - validate_tx!(server_keypair: server_keypair) + def to_envelope + @envelope.clone + end + + def validate!(**options) + validate_tx! validate_operations!(options) + + raise InvalidChallengeError, "The transaction is not signed by the server" unless @envelope.signed_by?(server) + end + + def client + @client ||= begin + auth_op = tx.operations&.first + auth_op && Stellar::KeyPair.from_public_key(auth_op.source_account.ed25519!) + end + end + + def client_domain_account_address + @client_domain_account_address = begin + client_domain_account_op = tx.operations.find { |op| op.body.value.data_name == "client_domain" } + client_domain_account_op && Util::StrKey.encode_muxed_account(client_domain_account_op.source_account) + end + end + + def verify_tx_signers(signers = []) + raise ArgumentError, "no signers provided" if signers.empty? + + # ignore non-G signers and server's own address + client_signers = signers.select { |s| s =~ /G[A-Z0-9]{55}/ && s != server.address }.to_set + raise ArgumentError, "at least one regular signer must be provided" if client_signers.empty? + + raise InvalidChallengeError, "transaction has no signatures." if envelope.signatures.empty? + + client_signers.add(client_domain_account_address) if client_domain_account_address.present? + + # verify all signatures in one pass + client_signers.add(server.address) + signers_found = verify_tx_signatures(tx_envelope: te, signers: client_signers) + + # ensure server signed transaction and remove it + unless signers_found.delete?(server.address) + raise InvalidChallengeError, "Transaction not signed by server: #{server.address}" + end + + # Confirm we matched signatures to the client signers. + if signers_found.empty? + raise InvalidChallengeError, "Transaction not signed by any client signer." + end + + # Confirm all signatures were consumed by a signer. + if signers_found.size != envelope.signatures.length - 1 + raise InvalidSep10ChallengeError, "Transaction has unrecognized signatures." + end + + if client_domain_account_address.present? && !signers_found.include?(client_domain_account_address) + raise InvalidSep10ChallengeError, "Transaction not signed by client domain account." + end + + signers_found end private - attr_reader :tx + attr_reader :tx, :server - def validate_tx!(server_keypair:) + def validate_tx! if tx.seq_num != 0 - raise InvalidSep10ChallengeError, "The transaction sequence number should be zero" + raise InvalidChallengeError, "The transaction sequence number should be zero" end - if tx.source_account != server_keypair.muxed_account - raise InvalidSep10ChallengeError, "The transaction source account is not equal to the server's account" + if tx.source_account != server.muxed_account + raise InvalidChallengeError, "The transaction source account is not equal to the server's account" end if tx.operations.size < 1 - raise InvalidSep10ChallengeError, "The transaction should contain at least one operation" + raise InvalidChallengeError, "The transaction should contain at least one operation" + end + + time_bounds = tx.cond.time_bounds + now = Time.now.to_i + + if time_bounds.blank? || !now.between?(time_bounds.min_time - GRACE_PERIOD, time_bounds.max_time + GRACE_PERIOD) + raise InvalidChallengeError, "The transaction has expired" end end - def validate_operations!(options) + def validate_operations!(**options) auth_op, *rest_ops = tx.operations client_account_id = auth_op.source_account auth_op_body = auth_op.body.value if client_account_id.blank? - raise InvalidSep10ChallengeError, "The transaction's operation should contain a source account" + raise InvalidChallengeError, "The transaction's operation should contain a source account" end if auth_op.body.arm != :manage_data_op - raise InvalidSep10ChallengeError, "The transaction's first operation should be manageData" + raise InvalidChallengeError, "The transaction's first operation should be manageData" end if options.key?(:domain) && auth_op_body.data_name != "#{options[:domain]} auth" - raise InvalidSep10ChallengeError, "The transaction's operation data name is invalid" + raise InvalidChallengeError, "The transaction's operation data name is invalid" end if auth_op_body.data_value.unpack1("m").size != 48 - raise InvalidSep10ChallengeError, "The transaction's operation value should be a 64 bytes base64 random string" + raise InvalidChallengeError, "The transaction's operation value should be a 64 bytes base64 random string" end rest_ops.each do |op| @@ -73,11 +149,11 @@ def validate_operations!(options) op_params = body.value if body.arm != :manage_data_op - raise InvalidSep10ChallengeError, "The transaction has operations that are not of type 'manageData'" - elsif op.source_account != server_keypair.muxed_account && op_params.data_name != "client_domain" - raise InvalidSep10ChallengeError, "The transaction has operations that are unrecognized" + raise InvalidChallengeError, "The transaction has operations that are not of type 'manageData'" + elsif op.source_account != server.muxed_account && op_params.data_name != "client_domain" + raise InvalidChallengeError, "The transaction has operations that are unrecognized" elsif op_params.data_name == "web_auth_domain" && options.key?(:auth_domain) && op_params.data_value != options[:auth_domain] - raise InvalidSep10ChallengeError, "The transaction has 'manageData' operation with 'web_auth_domain' key and invalid value" + raise InvalidChallengeError, "The transaction has 'manageData' operation with 'web_auth_domain' key and invalid value" end end end diff --git a/ecosystem/lib/stellar/sep10/challenge_tx_builder.rb b/ecosystem/lib/stellar/sep10/challenge_tx_builder.rb index 00eb55d0..f7e76019 100644 --- a/ecosystem/lib/stellar/sep10/challenge_tx_builder.rb +++ b/ecosystem/lib/stellar/sep10/challenge_tx_builder.rb @@ -3,7 +3,7 @@ module Ecosystem module SEP10 class ChallengeTxBuilder def self.build(server:, client:, domain: nil, timeout: 300, **options) - new(server: server, client: client, domain: domain, timeout: timeout, options).build + new(server: server, client: client, domain: domain, timeout: timeout, **options).build end def initialize(server:, client:, domain: nil, timeout: 300, **options) @@ -50,6 +50,7 @@ def time_bounds end def main_operation + puts client.address Stellar::Operation.manage_data( name: "#{domain} auth", value: SecureRandom.base64(48), diff --git a/ecosystem/lib/stellar/sep10/server.rb b/ecosystem/lib/stellar/sep10/server.rb index 966b62e9..d36688ac 100644 --- a/ecosystem/lib/stellar/sep10/server.rb +++ b/ecosystem/lib/stellar/sep10/server.rb @@ -22,9 +22,118 @@ def build_challenge(client:, domain: nil, timeout: 300, **options) client: client, domain: domain, timeout: timeout, - options + **options ) end + + # Verifies that for a SEP 10 challenge transaction all signatures on the transaction are accounted for. + # + # A transaction is verified if it is signed by the server account, and all other signatures match a signer + # that has been provided as an argument. Additional signers can be provided that do not have a signature, + # but all signatures must be matched to a signer for verification to succeed. + # + # If verification succeeds a list of signers that were found is returned, excluding the server account ID. + # + # @param challenge_xdr [String] SEP0010 transaction challenge transaction in base64. + # @param signers [] The signers of client account. + # + # @raise InvalidChallengeError one or more signatures in the transaction are not identifiable + # as the server account or one of the signers provided in the arguments + # + # @return [] subset of input signers who have signed `challenge_xdr` + def verify_challenge_tx_signers!(challenge_xdr:, signers:) + raise ArgumentError, "no signers provided" if signers.empty? + + # ignore non-G signers and server's own address + client_signers = signers.select { |s| s =~ /G[A-Z0-9]{55}/ && s != keypair.address }.to_set + raise ArgumentError, "at least one regular signer must be provided" if client_signers.empty? + + challenge = Challenge.read_xdr(challenge_xdr, server: keypair) + challenge.validate! + + client_signers.add(challenge.client_domain_account_address) if challenge.client_domain_account_address.present? + + # verify all signatures in one pass + client_signers.add(keypair.address) + tx_envelope = challenge.to_envelope + signers_found = verify_tx_signatures!(tx_envelope: tx_envelope, signers: client_signers) + + # ensure server signed transaction and remove it + unless signers_found.delete?(keypair.address) + raise InvalidChallengeError, "Transaction not signed by server: #{keypair}" + end + + # Confirm we matched signatures to the client signers. + if signers_found.empty? + raise InvalidChallengeError, "Transaction not signed by any client signer." + end + + # Confirm all signatures were consumed by a signer. + if signers_found.size != tx_envelope.signatures.length - 1 + raise InvalidChallengeError, "Transaction has unrecognized signatures." + end + + if challenge.client_domain_account_address.present? && !signers_found.include?(challenge.client_domain_account_address) + raise InvalidChallengeError, "Transaction not signed by client domain account." + end + + signers_found + end + + # Verifies that for a SEP 10 challenge transaction all signatures on the transaction + # are accounted for and that the signatures meet a threshold on an account. A + # transaction is verified if it is signed by the server account, and all other + # signatures match a signer that has been provided as an argument, and those + # signatures meet a threshold on the account. + # + # @param challenge_xdr [String] SEP0010 challenge transaction in base64. + # @param signers [{String => Integer}] The signers of client account. + # @param threshold [Integer] The medThreshold on the client account. + # + # @raise InvalidChallengeError if the transaction has unrecognized signatures (only server's + # signing key and keypairs found in the `signing` argument are recognized) or total weight of + # the signers does not meet the `threshold` + # + # @return [] subset of input signers who have signed `challenge_xdr` + def verify_challenge_tx_threshold!(challenge_xdr:, signers:, threshold:) + signers_found = verify_challenge_tx_signers!(challenge_xdr: challenge_xdr, signers: signers.keys) + + total_weight = signers.values_at(*signers_found).sum + + if total_weight < threshold + raise InvalidChallengeError, "signers with weight #{total_weight} do not meet threshold #{threshold}." + end + + signers_found + end + + private + + attr_reader :keypair + + # Verifies every signer passed matches a signature on the transaction exactly once, + # returning a list of unique signers that were found to have signed the transaction. + # + # @param tx_envelope [Stellar::TransactionEnvelope] SEP0010 transaction challenge transaction envelope. + # @param signers [] The signers of client account. + # + # @return [Set] + def verify_tx_signatures!(tx_envelope:, signers:) + signatures = tx_envelope.signatures + if signatures.empty? + raise InvalidChallengeError, "Transaction has no signatures." + end + + tx_hash = tx_envelope.tx.hash + to_keypair = Stellar::DSL.method(:KeyPair) + keys_by_hint = signers.map(&to_keypair).index_by(&:signature_hint) + + signatures.each_with_object(Set.new) do |sig, result| + key = keys_by_hint.delete(sig.hint) + result.add(key.address) if key&.verify(sig.signature, tx_hash) + end + end end end end +end diff --git a/ecosystem/spec/lib/stellar/sep10_v2_spec.rb b/ecosystem/spec/lib/stellar/sep10_v2_spec.rb new file mode 100644 index 00000000..b68b61c2 --- /dev/null +++ b/ecosystem/spec/lib/stellar/sep10_v2_spec.rb @@ -0,0 +1,446 @@ +RSpec.describe "SEP10" do + let(:server) { KeyPair() } + let(:user) { KeyPair() } + let(:domain) { "testnet.stellar.org" } + let(:options) { {} } + let(:nonce) { SecureRandom.base64(48) } + + let(:challenge) { Stellar::Ecosystem::SEP10::Challenge.build(server: server, client: user, domain: domain, **options) } + let(:envelope) { challenge.to_envelope } + let(:transaction) { envelope.tx } + + let(:signers) { [server, user] } + let(:response) { transaction.to_envelope(*signers) } + let(:response_xdr) { response.to_xdr(:base64) } + + describe ".build_challenge_tx" do + let(:attrs) { {server: server, client: user, domain: domain} } + + subject(:challenge_tx) do + challenge = Stellar::Ecosystem::SEP10::Challenge.build(**attrs) + challenge.to_envelope.tx + end + + it "generates a valid SEP10 challenge" do + expect(challenge_tx.seq_num).to eql(0) + expect(challenge_tx.operations.size).to eql(1) + expect(challenge_tx.source_account).to eql(server.muxed_account) + + time_bounds = challenge_tx.cond.time_bounds + expect(time_bounds.max_time - time_bounds.min_time).to eql(300) + + operation = challenge_tx.operations.first + expect(operation.source_account).to eql(user.muxed_account) + + body = operation.body + expect(body.arm).to eql(:manage_data_op) + expect(body.data_name).to eql("testnet.stellar.org auth") + expect(body.data_value.bytes.size).to eql(64) + expect(body.data_value.unpack1("m").size).to eql(48) + end + + it "allows to customize challenge timeout" do + attrs[:timeout] = 600 + + time_bounds = challenge_tx.cond.time_bounds + expect(time_bounds.max_time - time_bounds.min_time).to eql(600) + end + + it "allows to customize auth domain" do + attrs[:auth_domain] = "auth.example.com" + + expect(challenge_tx.operations.size).to eql(2) + + auth_domain_check_operation = challenge_tx.operations[1] + expect(auth_domain_check_operation.source_account).to eql(server.muxed_account) + + body = auth_domain_check_operation.body + expect(body.arm).to eql(:manage_data_op) + expect(body.data_name).to eql("web_auth_domain") + expect(body.data_value).to eql("auth.example.com") + end + + it "allows to set client domain" do + client_domain_account = Stellar::KeyPair.random + attrs[:client_domain_account] = client_domain_account + attrs[:client_domain] = "client.test" + + expect(challenge_tx.operations.size).to eql(2) + + client_domain_check_operation = challenge_tx.operations[1] + expect(client_domain_check_operation.source_account).to eq(client_domain_account.muxed_account) + + body = client_domain_check_operation.body + expect(body.arm).to eql(:manage_data_op) + expect(body.data_name).to eql("client_domain") + expect(body.data_value).to eql("client.test") + end + end + + describe "#read_challenge_tx" do + let(:attrs) { {challenge_xdr: response_xdr, server: server} } + + let(:extra_operation) do + Stellar::Operation.manage_data(source_account: server, name: "extra", value: "operation") + end + + let(:invalid_operation) do + Stellar::Operation.payment(source_account: server, destination: KeyPair(), amount: [:native, 20]) + end + + let(:auth_domain_operation) do + Stellar::Operation.manage_data(source_account: server, name: "web_auth_domain", value: "wrong.example.com") + end + + subject(:read_challenge) { Stellar::Ecosystem::SEP10::Challenge.read_xdr(response_xdr, server: attrs[:server]) } + + it "returns the envelope and client public key if the transaction is valid" do + p response.tx.operations.first.source_account.ed25519! + expect(read_challenge.to_envelope).to eq(response) + expect(read_challenge.client.address).to eq(user.address) + end + + it "returns the envelope even if transaction signed by server but not client" do + signers.replace([server]) + + expect { read_challenge.validate! }.not_to raise_error + end + + it "allows extra manage data operations with server as source" do + transaction.operations << extra_operation + + expect { read_challenge.validate! }.not_to raise_error + end + + context "when transaction sequence number is different to zero" do + before { transaction.seq_num = 1 } + + it "raises an error" do + expect { read_challenge.validate! }.to raise_invalid("sequence number should be zero") + # expect { read_challenge }.to raise_invalid("sequence number should be zero") + end + end + + context "when transaction source account is different to server account id" do + # random keypair + before { attrs[:server] = KeyPair() } + + it "raises an error" do + expect { read_challenge.validate! }.to raise_invalid("source account is not equal to the server's account") + # expect { read_challenge }.to raise_invalid("source account is not equal to the server's account") + end + end + + context "when transaction doesn't contain any operation" do + before { transaction.operations.clear } + + it "raises an error" do + expect { read_challenge.validate! }.to raise_invalid("should contain at least one operation") + end + end + + context "when operation does not contain the source account" do + before { transaction.operations.first.source_account = nil } + + it "raises an error" do + expect { read_challenge.validate! }.to raise_invalid("operation should contain a source account") + end + end + + context "when operation is not manage data" do + before { transaction.operations.replace([invalid_operation]) } + + it "raises an error" do + expect { read_challenge.validate! }.to raise_invalid("first operation should be manageData") + end + end + + context "when `domain` is provided for check" do + it "throws an error if operation data name does not contain home domain" do + expect { read_challenge.validate!(domain: "wrong.#{domain}") }.to raise_invalid("operation data name is invalid") + end + end + + it "throws an error if operation value is not a 64 bytes base64 string" do + transaction.operations.first.body.value.data_value = SecureRandom.random_bytes(64) + expect { read_challenge.validate! }.to raise_invalid("value should be a 64 bytes base64 random string") + end + + it "throws an error if transaction contains operations except manage data " do + transaction.operations << invalid_operation + + expect { read_challenge.validate! }.to raise_invalid("has operations that are not of type 'manageData'") + end + + it "throws an error if transaction contains extra operation not from the server" do + extra_operation.source_account = KeyPair().muxed_account + transaction.operations << extra_operation + + expect { read_challenge.validate! }.to raise_invalid("has operations that are unrecognized") + end + + it "throws an error if transaction is not signed by the server" do + signers.replace([user]) + + expect { read_challenge.validate! }.to raise_invalid("is not signed by the server") + end + + describe "transaction time bounds" do + context "when transaction does not contain timeBounds" do + before { transaction.cond = Stellar::Preconditions.new(:precond_none) } + + it "throws an error" do + expect { read_challenge.validate! }.to raise_invalid("has expired") + end + end + + it "uses 5 minutes grace period for validation" do + transaction.cond = Stellar::Preconditions.new( + :precond_time, + Stellar::TimeBounds.new( + min_time: 1.minute.from_now.to_i, + max_time: 2.minutes.from_now.to_i + ) + ) + expect { read_challenge.validate! }.not_to raise_error + + transaction.cond = Stellar::Preconditions.new( + :precond_time, + Stellar::TimeBounds.new( + min_time: 2.minutes.ago.to_i, + max_time: 1.minute.ago.to_i + ) + ) + expect { read_challenge }.not_to raise_error + end + + context "when challenge is expired beyond grace period" do + before do + transaction.cond = Stellar::Preconditions.new( + :precond_time, + Stellar::TimeBounds.new(min_time: 0, max_time: 5) + ) + end + + it "throws an error if challenge is expired" do + expect { read_challenge.validate! }.to raise_invalid("has expired") + end + end + + context "when challenge is in the future beyond grace period" do + it "throws an error" do + transaction.cond = Stellar::Preconditions.new( + :precond_time, + Stellar::TimeBounds.new( + min_time: 6.minutes.from_now.to_i, + max_time: 7.minutes.from_now.to_i + ) + ) + + expect { read_challenge.validate! }.to raise_invalid("has expired") + end + end + end + + it "throws an error if provided auth domain is wrong" do + options[:auth_domain] = "wrong.example.com" + attrs[:auth_domain] = "auth.example.com" + transaction.operations << auth_domain_operation + + expect { read_challenge.validate!(auth_domain: "auth.example.com") }.to raise_invalid("has 'manageData' operation with 'web_auth_domain' key and invalid value") + end + end + + describe "#verify_challenge_tx_threshold" do + let(:cosigner_a) { KeyPair() } + let(:cosigner_b) { KeyPair() } + let(:cosigner_c) { KeyPair() } + let(:cosigners) { Hash(user.address => 1, cosigner_a.address => 2, cosigner_b.address => 4) } + let(:signers) { [server, user, cosigner_a, cosigner_b] } + let(:args) { {} } + + subject(:verify_threshold) do + Stellar::Ecosystem::SEP10::Server + .new(keypair: server) + .verify_challenge_tx_threshold!( + challenge_xdr: response_xdr, + signers: cosigners, + threshold: 7, + **args + ) + end + + it "verifies proper challenge and threshold" do + expect(verify_threshold).to eq cosigners.keys.to_set + end + + it "verifies when not all cosigners have signed but threshold is met" do + signers.delete(cosigner_b) + args[:threshold] = 3 + + expect(verify_threshold).to contain_exactly(user.address, cosigner_a.address) + end + + it "ignores non-G address" do + signers.replace([server, user]) + cosigners.replace( + user.address => 1, + "TAQCSRX2RIDJNHFIFHWD63X7D7D6TRT5Y2S6E3TEMXTG5W3OECHZ2OG4" => 1, # pre_auth_tx + "XDRPF6NZRR7EEVO7ESIWUDXHAOMM2QSKIQQBJK6I2FB7YKDZES5UCLWD" => 1 # hash_x + ) + args[:threshold] = 1 + + expect(verify_threshold).to contain_exactly(user.address) + end + + it "raises if transaction not signed by server" do + signers.delete(server) + + expect { verify_threshold }.to raise_invalid("is not signed by the server") + end + + it "raises on signatures not from cosigners" do + signers << cosigner_c + args[:threshold] = 2 + + expect { verify_threshold }.to raise_invalid("has unrecognized signatures") + end + + it "raises error when signers don't meet threshold" do + args[:threshold] = 8 + + expect { verify_threshold }.to raise_invalid("signers with weight 7 do not meet threshold 8") + end + + it "raises no signers error" do + cosigners.replace({}) + expect { verify_threshold }.to raise_error(ArgumentError, "no signers provided") + end + + it "raises an error for no signatures" do + signers.replace([]) + expect { verify_threshold }.to raise_invalid("is not signed by the server") + end + + it "raises an error for duplicate signatures" do + signers.replace [server, user, user] + expect { verify_threshold }.to raise_invalid("has unrecognized signatures") + end + end + + describe "#verify_challenge_tx_signers" do + let(:cosigner_a) { KeyPair() } + let(:cosigner_b) { KeyPair() } + let(:cosigner_c) { KeyPair() } + let(:cosigners) { [user.address, cosigner_a.address, cosigner_b.address] } + let(:signers) { [server, user, cosigner_a, cosigner_b] } + + subject(:verify_signers) do + Stellar::Ecosystem::SEP10::Server.new(keypair: server).verify_challenge_tx_signers!( + challenge_xdr: response_xdr, + signers: cosigners + ) + end + + it "returns expected signatures" do + expect(verify_signers).to contain_exactly(user.address, cosigner_a.address, cosigner_b.address) + end + + it "succeeds even when the server is included in the passed signers" do + signers.replace [server, user] + cosigners.replace [server.address, user.address] + + expect(verify_signers).to contain_exactly(user.address) + end + + it "succeeds with extra signers passed" do + cosigners << cosigner_c.address + expect(verify_signers).to contain_exactly(user.address, cosigner_a.address, cosigner_b.address) + end + + it "does not pass back duplicate signers" do + signers.replace [server, user] + cosigners.replace [user.address, user.address, user.address] + expect(verify_signers).to contain_exactly(user.address) + end + + it "ignores non-G address" do + cosigners << "TAQCSRX2RIDJNHFIFHWD63X7D7D6TRT5Y2S6E3TEMXTG5W3OECHZ2OG4" # pre-auth tx + cosigners << "XDRPF6NZRR7EEVO7ESIWUDXHAOMM2QSKIQQBJK6I2FB7YKDZES5UCLWD" # hash(x) + + expect(verify_signers).to contain_exactly(user.address, cosigner_a.address, cosigner_b.address) + end + + it "raises no signers error" do + cosigners.clear + expect { verify_signers }.to raise_error(ArgumentError, "no signers provided") + end + + it "raises transaction not signed by server" do + signers.delete(server) + + expect { verify_signers }.to raise_invalid("is not signed by the server") + end + + it "raises no client signers found" do + cosigners.replace [KeyPair().address, KeyPair().address, KeyPair().address] + expect { verify_signers }.to raise_invalid("not signed by any client signer") + end + + it "raises unrecognized signatures" do + signers << KeyPair() + + expect { verify_signers }.to raise_invalid("has unrecognized signatures") + end + + it "raises an error when transaction only has server signature" do + cosigners.replace [server.address] + + expect { verify_signers }.to raise_error(ArgumentError, "at least one regular signer must be provided") + end + + it "raises an error for duplicate signatures" do + signers << user + expect { verify_signers }.to raise_invalid("has unrecognized signatures") + end + + it "raises an error for no signatures" do + signers.clear + + expect { verify_signers }.to raise_invalid("is not signed by the server") + end + + context "when client domain was provided" do + let(:client_domain_account) { Stellar::KeyPair.random } + let(:options) do + { + client_domain: "client_test", + client_domain_account: client_domain_account + } + end + + context "but transaction is not signed with client signature" do + it "raises an error" do + expect { verify_signers }.to raise_invalid("not signed by client domain account") + end + end + + context "and transaction is signed with client signature" do + before { signers << client_domain_account } + + it "returns expected signatures" do + expect(verify_signers).to contain_exactly( + user.address, + cosigner_a.address, + cosigner_b.address, + client_domain_account.address + ) + end + end + end + end + + def raise_invalid(cause) + raise_error(Stellar::Ecosystem::SEP10::InvalidChallengeError, Regexp.compile(cause)) + end +end diff --git a/ecosystem/spec/spec_helper.rb b/ecosystem/spec/spec_helper.rb index 7f2aeee2..d6c70737 100644 --- a/ecosystem/spec/spec_helper.rb +++ b/ecosystem/spec/spec_helper.rb @@ -1,8 +1,15 @@ -# frozen_string_literal: true +require "simplecov" +require "break" -require "stellar/ecosystem" +require "rspec/its" + +require_relative "../lib/stellar-ecosystem" RSpec.configure do |config| + config.include Stellar::DSL + config.filter_run_when_matching focus: true + config.run_all_when_everything_filtered = true + # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" diff --git a/ecosystem/spec/stellar/ecosystem_spec.rb b/ecosystem/spec/stellar/ecosystem_spec.rb deleted file mode 100644 index f61c6b87..00000000 --- a/ecosystem/spec/stellar/ecosystem_spec.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Stellar::Ecosystem do - it "has a version number" do - expect(Stellar::Ecosystem::VERSION).not_to be nil - end - - it "does something useful" do - expect(false).to eq(true) - end -end diff --git a/ecosystem/stellar-ecosystem.gemspec b/ecosystem/stellar-ecosystem.gemspec index 45109b71..53a5c3a9 100644 --- a/ecosystem/stellar-ecosystem.gemspec +++ b/ecosystem/stellar-ecosystem.gemspec @@ -4,35 +4,29 @@ require_relative "lib/stellar/ecosystem/version" Gem::Specification.new do |spec| spec.name = "stellar-ecosystem" - spec.version = Stellar::Ecosystem::VERSION - spec.authors = ["Timur Ramazanov"] - spec.email = ["charlie.vmk@gmail.com"] - - spec.summary = "TODO: Write a short summary, because RubyGems requires one." - spec.description = "TODO: Write a longer description or delete this line." - spec.homepage = "TODO: Put your gem's website or public repo URL here." - spec.required_ruby_version = ">= 2.6.0" - - spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" + spec.version = Stellar::VERSION + spec.authors = ["Timur Ramazanov", "Sergey Nebolsin"] + spec.summary = "Stellar ecosystem library" + spec.homepage = "https://github.com/astroband/ruby-stellar-sdk" + spec.license = "Apache-2.0" + + spec.files = Dir["lib/**/*"] + spec.extra_rdoc_files += Dir["README*", "LICENSE*", "CHANGELOG*"] + spec.require_paths = ["lib"] + spec.bindir = "exe" - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." - spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." + spec.metadata = { + "bug_tracker_uri" => "#{spec.homepage}/issues", + "changelog_uri" => "#{spec.homepage}/blob/v#{spec.version}/sdk/CHANGELOG.md", + "documentation_uri" => "https://rubydoc.info/gems/#{spec.name}/#{spec.version}/", + "github_repo" => spec.homepage.sub("https", "ssh"), + "homepage_uri" => "#{spec.homepage}/tree/main/sdk", + "source_code_uri" => "#{spec.homepage}/tree/v#{spec.version}/sdk" + } - # Specify which files should be added to the gem when it is released. - # The `git ls-files -z` loads the files in the RubyGem that have been added into git. - spec.files = Dir.chdir(File.expand_path(__dir__)) do - `git ls-files -z`.split("\x0").reject do |f| - (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) - end - end - spec.bindir = "exe" - spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } - spec.require_paths = ["lib"] + spec.required_ruby_version = ">= 2.5.0" - # Uncomment to register a new dependency of your gem - # spec.add_dependency "example-gem", "~> 1.0" + spec.add_dependency "stellar-base", spec.version - # For more information and examples about making a new gem, check out our - # guide at: https://bundler.io/guides/creating_gem.html + # spec.add_dependency "activesupport", ">= 5.0.0", "< 8.0" end diff --git a/sdk/examples/07_sep10.rb b/sdk/examples/07_sep10.rb index 9d13b5ca..24b3abf6 100644 --- a/sdk/examples/07_sep10.rb +++ b/sdk/examples/07_sep10.rb @@ -56,7 +56,7 @@ def setup_multisig def example_verify_challenge_tx_threshold # 1. The wallet makes a GET request to /auth, # 2. The server receives the request, and returns the challenge xdr. - envelope_xdr = Stellar::SEP10.build_challenge_tx( + envelope_xdr = Stellar::Ecosystem::SEP10::Challenge.new( server: $server_kp, client: $client_master_kp, anchor_name: "SDF", diff --git a/sdk/lib/stellar/sep10.rb b/sdk/lib/stellar/sep10.rb index cf9491d3..85ef8442 100644 --- a/sdk/lib/stellar/sep10.rb +++ b/sdk/lib/stellar/sep10.rb @@ -235,7 +235,7 @@ def self.verify_challenge_tx_signers(server:, challenge_xdr:, signers:) # ensure server signed transaction and remove it unless signers_found.delete?(server.address) - raise InvalidSep10ChallengeError, "Transaction not signed by server: #{server.address}" + raise InvalidSep10ChallengeError, "Transaction not signed by server: #{keypair}" end # Confirm we matched signatures to the client signers.