Skip to content

Commit

Permalink
chore(sep-10): refactor SEP implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
charlie-wasp committed Dec 26, 2022
1 parent 808be6e commit a5ec2d8
Show file tree
Hide file tree
Showing 12 changed files with 720 additions and 66 deletions.
8 changes: 8 additions & 0 deletions base/lib/stellar/transaction_envelope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
20 changes: 20 additions & 0 deletions base/spec/lib/stellar/transaction_envelope_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions ecosystem/lib/stellar-ecosystem.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
120 changes: 98 additions & 22 deletions ecosystem/lib/stellar/sep10/challenge.rb
Original file line number Diff line number Diff line change
@@ -1,83 +1,159 @@
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|
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_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
Expand Down
3 changes: 2 additions & 1 deletion ecosystem/lib/stellar/sep10/challenge_tx_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down
111 changes: 110 additions & 1 deletion ecosystem/lib/stellar/sep10/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 [<String>] 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 [<String>] 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 [<String>] 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 [<String>] The signers of client account.
#
# @return [Set<Stellar::KeyPair>]
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
Loading

0 comments on commit a5ec2d8

Please sign in to comment.