Class: Stellar::SEP10

Inherits:
Object
  • Object
show all
Includes:
DSL
Defined in:
lib/stellar/sep10.rb

Constant Summary collapse

GRACE_PERIOD =

We use a small grace period for the challenge transaction time bounds to compensate possible clock drift on client’s machine

5.minutes

Class Method Summary collapse

Class Method Details

.build_challenge_tx(server:, client:, domain: nil, timeout: 300, **options) ⇒ String

Helper method to create a valid SEP0010 challenge transaction which you can use for Stellar Web Authentication.

Examples:

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)

Parameters:

  • server (Stellar::KeyPair)

    server’s signing keypair (SIGNING_KEY in service’s stellar.toml)

  • client (Stellar::KeyPair)

    account trying to authenticate with the server

  • domain (String) (defaults to: nil)

    service’s domain to be used in the manage_data key

  • timeout (Integer) (defaults to: 300)

    challenge duration (default to 5 minutes)

Returns:

  • (String)

    A base64 encoded string of the raw TransactionEnvelope xdr struct for the transaction.

See Also:

  • Stellar Web Authentication}[https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md]


27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/stellar/sep10.rb', line 27

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

.extract_client_domain_account(transaction) ⇒ Object



299
300
301
302
303
304
305
306
307
308
# File 'lib/stellar/sep10.rb', line 299

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

  return if .blank?

  Util::StrKey.(.)
end

.read_challenge_tx(server:, challenge_xdr:, **options) ⇒ Array(Stellar::TransactionEnvelope, String)

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

Examples:

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)

Parameters:

  • challenge_xdr (String)

    SEP0010 transaction challenge in base64.

  • server (Stellar::KeyPair)

    keypair for server where the challenge was generated.

Returns:

  • (Array(Stellar::TransactionEnvelope, String))


107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/stellar/sep10.rb', line 107

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. != server.
    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
   = auth_op.

  auth_op_body = auth_op.body.value

  if .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. != server. && 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(.ed25519!)

  [envelope, client_kp.address]
end

.verify_challenge_tx_signers(server:, challenge_xdr:, signers:) ⇒ <String>

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.

Parameters:

  • server (Stellar::Keypair)

    server’s signing key

  • challenge_xdr (String)

    SEP0010 transaction challenge transaction in base64.

  • signers (<String>)

    The signers of client account.

Returns:

  • (<String>)

    subset of input signers who have signed ‘challenge_xdr`

Raises:

  • InvalidSep10ChallengeError one or more signatures in the transaction are not identifiable as the server account or one of the signers provided in the arguments



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/stellar/sep10.rb', line 220

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?

   = (te.tx)
  client_signers.add() if .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 .present? && !signers_found.include?()
    raise InvalidSep10ChallengeError, "Transaction not signed by client domain account."
  end

  signers_found
end

.verify_challenge_tx_threshold(server:, challenge_xdr:, signers:, threshold:) ⇒ <String>

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.

Parameters:

  • server (Stellar::KeyPair)

    keypair for server’s account.

  • challenge_xdr (String)

    SEP0010 challenge transaction in base64.

  • signers ({String => Integer})

    The signers of client account.

  • threshold (Integer)

    The medThreshold on the client account.

Returns:

  • (<String>)

    subset of input signers who have signed ‘challenge_xdr`

Raises:

  • 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`



190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/stellar/sep10.rb', line 190

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

.verify_tx_signatures(tx_envelope:, signers:) ⇒ Set<Stellar::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.

Parameters:

  • tx_envelope (Stellar::TransactionEnvelope)

    SEP0010 transaction challenge transaction envelope.

  • signers (<String>)

    The signers of client account.

Returns:

  • (Set<Stellar::KeyPair>)


265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/stellar/sep10.rb', line 265

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

.verify_tx_signed_by(tx_envelope:, keypair:) ⇒ Boolean

Verifies if a Stellar::TransactionEnvelope was signed by the given Stellar::KeyPair

Examples:

Stellar::SEP10.verify_tx_signed_by(tx_envelope: envelope, keypair: keypair)

Parameters:

  • tx_envelope (Stellar::TransactionEnvelope)
  • keypair (Stellar::KeyPair)

Returns:

  • (Boolean)


290
291
292
293
294
295
296
297
# File 'lib/stellar/sep10.rb', line 290

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