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..a3964c4a 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/.rspec b/ecosystem/.rspec new file mode 100644 index 00000000..34c5164d --- /dev/null +++ b/ecosystem/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/ecosystem/CHANGELOG.md b/ecosystem/CHANGELOG.md new file mode 100644 index 00000000..1a035827 --- /dev/null +++ b/ecosystem/CHANGELOG.md @@ -0,0 +1,5 @@ +## [Unreleased] + +## [0.1.0] - 2022-10-29 + +- Initial release diff --git a/ecosystem/README.md b/ecosystem/README.md new file mode 100644 index 00000000..e7d3e8cc --- /dev/null +++ b/ecosystem/README.md @@ -0,0 +1,35 @@ +# Stellar::Ecosystem + +Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/stellar/ecosystem`. To experiment with that code, run `bin/console` for an interactive prompt. + +TODO: Delete this and the text above, and describe your gem + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'stellar-ecosystem' +``` + +And then execute: + + $ bundle install + +Or install it yourself as: + + $ gem install stellar-ecosystem + +## Usage + +TODO: Write usage instructions here + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/stellar-ecosystem. diff --git a/ecosystem/Rakefile b/ecosystem/Rakefile new file mode 100644 index 00000000..df406778 --- /dev/null +++ b/ecosystem/Rakefile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +require "standard/rake" + +task default: %i[spec standard] diff --git a/ecosystem/bin/console b/ecosystem/bin/console new file mode 100755 index 00000000..719aef6c --- /dev/null +++ b/ecosystem/bin/console @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "stellar/ecosystem" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/ecosystem/bin/setup b/ecosystem/bin/setup new file mode 100755 index 00000000..dce67d86 --- /dev/null +++ b/ecosystem/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/ecosystem/lib/stellar-ecosystem.rb b/ecosystem/lib/stellar-ecosystem.rb new file mode 100644 index 00000000..4714acc8 --- /dev/null +++ b/ecosystem/lib/stellar-ecosystem.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "stellar-base" + +module Stellar + 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/ecosystem/version.rb b/ecosystem/lib/stellar/ecosystem/version.rb new file mode 100644 index 00000000..991792e9 --- /dev/null +++ b/ecosystem/lib/stellar/ecosystem/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Stellar + module Ecosystem + VERSION = "0.1.0" + end +end diff --git a/ecosystem/lib/stellar/sep10/challenge.rb b/ecosystem/lib/stellar/sep10/challenge.rb new file mode 100644 index 00000000..9800e1c0 --- /dev/null +++ b/ecosystem/lib/stellar/sep10/challenge.rb @@ -0,0 +1,163 @@ +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 + ) + + new(envelope: tx.to_envelope(server), server: server) + end + + def self.read_xdr(xdr, server:) + envelope = Stellar::TransactionEnvelope.from_xdr(xdr, "base64") + new(envelope: envelope, server: server) + end + + def initialize(envelope:, server:) + @envelope = envelope + @tx = envelope.tx + @server = server + end + + def to_xdr + @envelope.to_xdr(:base64) + end + + 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, :server + + def validate_tx! + if tx.seq_num != 0 + raise InvalidChallengeError, "The transaction sequence number should be zero" + end + + 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 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) + 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 InvalidChallengeError, "The transaction's operation should contain a source account" + end + + if auth_op.body.arm != :manage_data_op + raise InvalidChallengeError, "The transaction's first operation should be manageData" + end + + if options.key?(:domain) && auth_op_body.data_name != "#{options[:domain]} auth" + raise InvalidChallengeError, "The transaction's operation data name is invalid" + end + + if auth_op_body.data_value.unpack1("m").size != 48 + raise InvalidChallengeError, "The transaction's operation value should be a 64 bytes base64 random string" + end + + rest_ops.each do |op| + body = op.body + op_params = body.value + + if body.arm != :manage_data_op + 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 InvalidChallengeError, "The transaction has 'manageData' operation with 'web_auth_domain' key and invalid value" + end + end + end + end + end + end +end diff --git a/ecosystem/lib/stellar/sep10/challenge_tx_builder.rb b/ecosystem/lib/stellar/sep10/challenge_tx_builder.rb new file mode 100644 index 00000000..09d1a428 --- /dev/null +++ b/ecosystem/lib/stellar/sep10/challenge_tx_builder.rb @@ -0,0 +1,79 @@ +module Stellar + 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 + end + + def initialize(server:, client:, domain: nil, timeout: 300, **options) + @server = server + @client = client + @timeout = timeout + @domain = domain + @options = options + end + + def build + tb = Stellar::TransactionBuilder.new( + source_account: server, + sequence_number: 0, + time_bounds: time_bounds + ) + + tb.add_operation(main_operation) + tb.add_operation(auth_domain_operation) if options.key?(:auth_domain) + + if options[:client_domain].present? + if options[:client_domain_account].blank? + raise "`client_domain_account` is required, if `client_domain` is provided" + end + + tb.add_operation(client_domain_operation) + end + + tb.build + end + + private + + attr_reader :server, :client, :timeout, :domain, :options, :tx + + def time_bounds + @time_bounds ||= begin + now = Time.now.to_i + Stellar::TimeBounds.new( + min_time: now, + max_time: now + timeout + ) + end + end + + def main_operation + puts client.address + Stellar::Operation.manage_data( + name: "#{domain} auth", + value: SecureRandom.base64(48), + source_account: client + ) + end + + def auth_domain_operation + Stellar::Operation.manage_data( + name: "web_auth_domain", + value: options[:auth_domain], + source_account: server + ) + end + + def client_domain_operation + Stellar::Operation.manage_data( + name: "client_domain", + value: options[:client_domain], + source_account: options[:client_domain_account] + ) + end + end + end + end +end diff --git a/ecosystem/lib/stellar/sep10/server.rb b/ecosystem/lib/stellar/sep10/server.rb new file mode 100644 index 00000000..d36688ac --- /dev/null +++ b/ecosystem/lib/stellar/sep10/server.rb @@ -0,0 +1,139 @@ +module Stellar + module Ecosystem + module SEP10 + class Server + def initialize(keypair:) + @keypair = keypair + end + + def build_challenge(client:, domain: nil, timeout: 300, **options) + if domain.blank? && options.key?(:anchor_name) + ActiveSupport::Deprecation.new("next release", "stellar-sdk").warn <<~MSG + SEP-10 v2.0.0 requires usage of service home domain instead of anchor name in the challenge transaction. + Please update your implementation to use `Stellar::SEP10.build_challenge_tx(..., home_domain: 'example.com')`. + Using `anchor_name` parameter makes your service incompatible with SEP10-2.0 clients, support for this parameter + is deprecated and will be removed in the next major release of stellar-base. + MSG + domain = options[:anchor_name] + end + + Challenge.new( + server: @keypair, + client: client, + domain: domain, + timeout: timeout, + **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..39d4f9d5 --- /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 new file mode 100644 index 00000000..d6c70737 --- /dev/null +++ b/ecosystem/spec/spec_helper.rb @@ -0,0 +1,22 @@ +require "simplecov" +require "break" + +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" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end diff --git a/ecosystem/stellar-ecosystem.gemspec b/ecosystem/stellar-ecosystem.gemspec new file mode 100644 index 00000000..53a5c3a9 --- /dev/null +++ b/ecosystem/stellar-ecosystem.gemspec @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative "lib/stellar/ecosystem/version" + +Gem::Specification.new do |spec| + spec.name = "stellar-ecosystem" + 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 = { + "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" + } + + spec.required_ruby_version = ">= 2.5.0" + + spec.add_dependency "stellar-base", spec.version + + # 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 5cbba5b2..07978dd1 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.