astroband/ruby-stellar-sdk

View on GitHub
sdk/lib/stellar/sep10.rb

Summary

Maintainability
C
7 hrs
Test Coverage
A
96%
module Stellar
  class InvalidSep10ChallengeError < StandardError; end

  class SEP10
    include Stellar::DSL

    # 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

    # Helper method to create a valid [SEP-10](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md)
    # challenge transaction which you can use for Stellar Web Authentication.
    #
    # @example
    #   server = Stellar::KeyPair.random # SIGNING_KEY from your stellar.toml
    #   user = Stellar::KeyPair.from_address('G...')
    #   Stellar::SEP10.build_challenge_tx(server: server, client: user, domain: 'example.com', timeout: 300)
    #
    # @param server [Stellar::KeyPair] server's signing keypair (SIGNING_KEY in service's stellar.toml)
    # @param client [Stellar::KeyPair] account trying to authenticate with the server
    # @param domain [String] service's domain to be used in the manage_data key
    # @param timeout [Integer] challenge duration (default to 5 minutes)
    #
    # @return [String] A base64 encoded string of the raw TransactionEnvelope xdr struct for the transaction.
    #
    # @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md SEP0010: Stellar Web Authentication
    def self.build_challenge_tx(server:, 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

      now = Time.now.to_i
      time_bounds = Stellar::TimeBounds.new(
        min_time: now,
        max_time: now + timeout
      )

      tb = Stellar::TransactionBuilder.new(
        source_account: server,
        sequence_number: 0,
        time_bounds: time_bounds
      )

      # The value must be 64 bytes long. It contains a 48 byte
      # cryptographic-quality random string encoded using base64 (for a total of
      # 64 bytes after encoding).
      tb.add_operation(
        Stellar::Operation.manage_data(
          name: "#{domain} auth",
          value: SecureRandom.base64(48),
          source_account: client
        )
      )

      if options.key?(:auth_domain)
        tb.add_operation(
          Stellar::Operation.manage_data(
            name: "web_auth_domain",
            value: options[:auth_domain],
            source_account: server
          )
        )
      end

      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(
          Stellar::Operation.manage_data(
            name: "client_domain",
            value: options[:client_domain],
            source_account: options[:client_domain_account]
          )
        )
      end

      tb.build.to_envelope(server).to_xdr(:base64)
    end

    # Reads a SEP 10 challenge transaction and returns the decoded transaction envelope and client account ID contained within.
    #
    # It also verifies that transaction is signed by the server.
    #
    # It does not verify that the transaction has been signed by the client or
    # that any signatures other than the servers on the transaction are valid.
    # Use either {.verify_challenge_tx_threshold} or {.verify_challenge_tx_signers} to completely verify
    # the signed challenge
    #
    # @example
    #   sep10 = Stellar::SEP10
    #   server = Stellar::KeyPair.random # this should be the SIGNING_KEY from your stellar.toml
    #   challenge = sep10.build_challenge_tx(server: server, client: user, domain: domain, timeout: timeout)
    #   envelope, client_address = sep10.read_challenge_tx(server: server, challenge_xdr: challenge)
    #
    # @param challenge_xdr [String] SEP0010 transaction challenge in base64.
    # @param server [Stellar::KeyPair] keypair for server where the challenge was generated.
    #
    # @return [Array(Stellar::TransactionEnvelope, String)]
    def self.read_challenge_tx(server:, challenge_xdr:, **options)
      envelope = Stellar::TransactionEnvelope.from_xdr(challenge_xdr, "base64")
      transaction = envelope.tx

      if transaction.seq_num != 0
        raise InvalidSep10ChallengeError, "The transaction sequence number should be zero"
      end

      if transaction.source_account != server.muxed_account
        raise InvalidSep10ChallengeError, "The transaction source account is not equal to the server's account"
      end

      if transaction.operations.size < 1
        raise InvalidSep10ChallengeError, "The transaction should contain at least one operation"
      end

      auth_op, *rest_ops = transaction.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"
      end

      if auth_op.body.arm != :manage_data_op
        raise InvalidSep10ChallengeError, "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"
      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"
      end

      rest_ops.each do |op|
        body = op.body
        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.muxed_account && op_params.data_name != "client_domain"
          raise InvalidSep10ChallengeError, "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"
        end
      end

      unless verify_tx_signed_by(tx_envelope: envelope, keypair: server)
        raise InvalidSep10ChallengeError, "The transaction is not signed by the server"
      end

      time_bounds = transaction.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 InvalidSep10ChallengeError, "The transaction has expired"
      end

      # Mirror the return type of the other SDK's and return a string
      client_kp = Stellar::KeyPair.from_public_key(client_account_id.ed25519!)

      [envelope, client_kp.address]
    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 server [Stellar::KeyPair] keypair for server's 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 InvalidSep10ChallengeError 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 [<String>] subset of input signers who have signed `challenge_xdr`
    def self.verify_challenge_tx_threshold(server:, challenge_xdr:, signers:, threshold:)
      signers_found = verify_challenge_tx_signers(
        server: server, challenge_xdr: challenge_xdr, signers: signers.keys
      )

      total_weight = signers.values_at(*signers_found).sum

      if total_weight < threshold
        raise InvalidSep10ChallengeError, "signers with weight #{total_weight} do not meet threshold #{threshold}."
      end

      signers_found
    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 server [Stellar::Keypair]  server's signing key
    # @param challenge_xdr [String] SEP0010 transaction challenge transaction in base64.
    # @param signers [<String>] The signers of client account.
    #
    # @raise InvalidSep10ChallengeError one or more signatures in the transaction are not identifiable
    #   as the server account or one of the signers provided in the arguments
    #
    # @return [<String>] subset of input signers who have signed `challenge_xdr`
    def self.verify_challenge_tx_signers(server:, challenge_xdr:, signers:)
      raise InvalidSep10ChallengeError, "no signers provided" if signers.empty?

      te, _ = read_challenge_tx(server: server, challenge_xdr: challenge_xdr)

      # 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 InvalidSep10ChallengeError, "at least one regular signer must be provided" if client_signers.empty?

      client_domain_account_address = extract_client_domain_account(te.tx)
      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 InvalidSep10ChallengeError, "Transaction not signed by server: #{server.address}"
      end

      # Confirm we matched signatures to the client signers.
      if signers_found.empty?
        raise InvalidSep10ChallengeError, "Transaction not signed by any client signer."
      end

      # Confirm all signatures were consumed by a signer.
      if signers_found.size != te.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

    # 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 [<String>] The signers of client account.
    #
    # @return [Set<Stellar::KeyPair>]
    def self.verify_tx_signatures(tx_envelope:, signers:)
      signatures = tx_envelope.signatures
      if signatures.empty?
        raise InvalidSep10ChallengeError, "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

    # Verifies if a Stellar::TransactionEnvelope was signed by the given Stellar::KeyPair
    #
    # @example
    #   Stellar::SEP10.verify_tx_signed_by(tx_envelope: envelope, keypair: keypair)
    #
    # @param tx_envelope [Stellar::TransactionEnvelope]
    # @param keypair [Stellar::KeyPair]
    #
    # @return [Boolean]
    def self.verify_tx_signed_by(tx_envelope:, keypair:)
      tx_hash = tx_envelope.tx.hash
      tx_envelope.signatures.any? do |sig|
        next if sig.hint != keypair.signature_hint

        keypair.verify(sig.signature, tx_hash)
      end
    end

    def self.extract_client_domain_account(transaction)
      client_domain_account_op =
        transaction
          .operations
          .find { |op| op.body.value.data_name == "client_domain" }

      return if client_domain_account_op.blank?

      Util::StrKey.encode_muxed_account(client_domain_account_op.source_account)
    end
  end
end