class Stellar::SEP10
Public Class Methods
Helper method to create a valid SEP0010 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 SEP0010: Stellar Web Authentication
# File lib/stellar/sep10.rb, line 23 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
# File lib/stellar/sep10.rb, line 295 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
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)]
# File lib/stellar/sep10.rb, line 103 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.time_bounds now = Time.now.to_i if time_bounds.blank? || !now.between?(time_bounds.min_time, time_bounds.max_time) 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.
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`
# File lib/stellar/sep10.rb, line 216 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 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`
# File lib/stellar/sep10.rb, line 186 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 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>]
# File lib/stellar/sep10.rb, line 261 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]
# File lib/stellar/sep10.rb, line 286 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