• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

astroband / ruby-stellar-sdk / 6271935013

22 Sep 2023 08:26AM UTC coverage: 94.551% (+0.03%) from 94.518%
6271935013

Pull #286

github

web-flow
Merge branch 'main' into chore/refactor-sep10
Pull Request #286: chore: refactor SEP-10

264 of 402 branches covered (0.0%)

Branch coverage included in aggregate %.

15 of 15 new or added lines in 3 files covered. (100.0%)

5184 of 5360 relevant lines covered (96.72%)

9.65 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.6
/sdk/lib/stellar/sep10.rb
1
module Stellar
1✔
2
  class InvalidSep10ChallengeError < StandardError; end
1✔
3

4
  class SEP10
1✔
5
    include Stellar::DSL
1✔
6

7
    # We use a small grace period for the challenge transaction time bounds
8
    # to compensate possible clock drift on client's machine
9
    GRACE_PERIOD = 5.minutes
1✔
10

11
    # Helper method to create a valid [SEP-10](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md)
12
    # challenge transaction which you can use for Stellar Web Authentication.
13
    #
14
    # @example
15
    #   server = Stellar::KeyPair.random # SIGNING_KEY from your stellar.toml
16
    #   user = Stellar::KeyPair.from_address('G...')
17
    #   Stellar::SEP10.build_challenge_tx(server: server, client: user, domain: 'example.com', timeout: 300)
18
    #
19
    # @param server [Stellar::KeyPair] server's signing keypair (SIGNING_KEY in service's stellar.toml)
20
    # @param client [Stellar::KeyPair] account trying to authenticate with the server
21
    # @param domain [String] service's domain to be used in the manage_data key
22
    # @param timeout [Integer] challenge duration (default to 5 minutes)
23
    #
24
    # @return [String] A base64 encoded string of the raw TransactionEnvelope xdr struct for the transaction.
25
    #
26
    # @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md SEP0010: Stellar Web Authentication
27
    def self.build_challenge_tx(server:, client:, domain: nil, timeout: 300, **options)
1✔
28
      if domain.blank? && options.key?(:anchor_name)
48!
29
        ActiveSupport::Deprecation.new("next release", "stellar-sdk").warn <<~MSG
×
30
          SEP-10 v2.0.0 requires usage of service home domain instead of anchor name in the challenge transaction.
31
          Please update your implementation to use `Stellar::SEP10.build_challenge_tx(..., home_domain: 'example.com')`.
32
          Using `anchor_name` parameter makes your service incompatible with SEP10-2.0 clients, support for this parameter
33
          is deprecated and will be removed in the next major release of stellar-base.
34
        MSG
35
        domain = options[:anchor_name]
×
36
      end
37

38
      now = Time.now.to_i
48✔
39
      time_bounds = Stellar::TimeBounds.new(
48✔
40
        min_time: now,
41
        max_time: now + timeout
42
      )
43

44
      tb = Stellar::TransactionBuilder.new(
48✔
45
        source_account: server,
46
        sequence_number: 0,
47
        time_bounds: time_bounds
48
      )
49

50
      # The value must be 64 bytes long. It contains a 48 byte
51
      # cryptographic-quality random string encoded using base64 (for a total of
52
      # 64 bytes after encoding).
53
      tb.add_operation(
48✔
54
        Stellar::Operation.manage_data(
55
          name: "#{domain} auth",
56
          value: SecureRandom.base64(48),
57
          source_account: client
58
        )
59
      )
60

61
      if options.key?(:auth_domain)
48✔
62
        tb.add_operation(
2✔
63
          Stellar::Operation.manage_data(
64
            name: "web_auth_domain",
65
            value: options[:auth_domain],
66
            source_account: server
67
          )
68
        )
69
      end
70

71
      if options[:client_domain].present?
48✔
72
        if options[:client_domain_account].blank?
3!
73
          raise "`client_domain_account` is required, if `client_domain` is provided"
×
74
        end
75

76
        tb.add_operation(
3✔
77
          Stellar::Operation.manage_data(
78
            name: "client_domain",
79
            value: options[:client_domain],
80
            source_account: options[:client_domain_account]
81
          )
82
        )
83
      end
84

85
      tb.build.to_envelope(server).to_xdr(:base64)
48✔
86
    end
87

88
    # Reads a SEP 10 challenge transaction and returns the decoded transaction envelope and client account ID contained within.
89
    #
90
    # It also verifies that transaction is signed by the server.
91
    #
92
    # It does not verify that the transaction has been signed by the client or
93
    # that any signatures other than the servers on the transaction are valid.
94
    # Use either {.verify_challenge_tx_threshold} or {.verify_challenge_tx_signers} to completely verify
95
    # the signed challenge
96
    #
97
    # @example
98
    #   sep10 = Stellar::SEP10
99
    #   server = Stellar::KeyPair.random # this should be the SIGNING_KEY from your stellar.toml
100
    #   challenge = sep10.build_challenge_tx(server: server, client: user, domain: domain, timeout: timeout)
101
    #   envelope, client_address = sep10.read_challenge_tx(server: server, challenge_xdr: challenge)
102
    #
103
    # @param challenge_xdr [String] SEP0010 transaction challenge in base64.
104
    # @param server [Stellar::KeyPair] keypair for server where the challenge was generated.
105
    #
106
    # @return [Array(Stellar::TransactionEnvelope, String)]
107
    def self.read_challenge_tx(server:, challenge_xdr:, **options)
1✔
108
      envelope = Stellar::TransactionEnvelope.from_xdr(challenge_xdr, "base64")
39✔
109
      transaction = envelope.tx
39✔
110

111
      if transaction.seq_num != 0
39✔
112
        raise InvalidSep10ChallengeError, "The transaction sequence number should be zero"
1✔
113
      end
114

115
      if transaction.source_account != server.muxed_account
38✔
116
        raise InvalidSep10ChallengeError, "The transaction source account is not equal to the server's account"
1✔
117
      end
118

119
      if transaction.operations.size < 1
37✔
120
        raise InvalidSep10ChallengeError, "The transaction should contain at least one operation"
1✔
121
      end
122

123
      auth_op, *rest_ops = transaction.operations
36✔
124
      client_account_id = auth_op.source_account
36✔
125

126
      auth_op_body = auth_op.body.value
36✔
127

128
      if client_account_id.blank?
36✔
129
        raise InvalidSep10ChallengeError, "The transaction's operation should contain a source account"
1✔
130
      end
131

132
      if auth_op.body.arm != :manage_data_op
35✔
133
        raise InvalidSep10ChallengeError, "The transaction's first operation should be manageData"
1✔
134
      end
135

136
      if options.key?(:domain) && auth_op_body.data_name != "#{options[:domain]} auth"
34✔
137
        raise InvalidSep10ChallengeError, "The transaction's operation data name is invalid"
1✔
138
      end
139

140
      if auth_op_body.data_value.unpack1("m").size != 48
33✔
141
        raise InvalidSep10ChallengeError, "The transaction's operation value should be a 64 bytes base64 random string"
1✔
142
      end
143

144
      rest_ops.each do |op|
32✔
145
        body = op.body
6✔
146
        op_params = body.value
6✔
147

148
        if body.arm != :manage_data_op
6✔
149
          raise InvalidSep10ChallengeError, "The transaction has operations that are not of type 'manageData'"
1✔
150
        elsif op.source_account != server.muxed_account && op_params.data_name != "client_domain"
5✔
151
          raise InvalidSep10ChallengeError, "The transaction has operations that are unrecognized"
1✔
152
        elsif op_params.data_name == "web_auth_domain" && options.key?(:auth_domain) && op_params.data_value != options[:auth_domain]
4✔
153
          raise InvalidSep10ChallengeError, "The transaction has 'manageData' operation with 'web_auth_domain' key and invalid value"
1✔
154
        end
155
      end
156

157
      unless verify_tx_signed_by(tx_envelope: envelope, keypair: server)
29✔
158
        raise InvalidSep10ChallengeError, "The transaction is not signed by the server"
5✔
159
      end
160

161
      time_bounds = transaction.cond.time_bounds
24✔
162
      now = Time.now.to_i
24✔
163

164
      if time_bounds.blank? || !now.between?(time_bounds.min_time - GRACE_PERIOD, time_bounds.max_time + GRACE_PERIOD)
24✔
165
        raise InvalidSep10ChallengeError, "The transaction has expired"
3✔
166
      end
167

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

171
      [envelope, client_kp.address]
21✔
172
    end
173

174
    # Verifies that for a SEP 10 challenge transaction all signatures on the transaction
175
    # are accounted for and that the signatures meet a threshold on an account. A
176
    # transaction is verified if it is signed by the server account, and all other
177
    # signatures match a signer that has been provided as an argument, and those
178
    # signatures meet a threshold on the account.
179
    #
180
    # @param server [Stellar::KeyPair] keypair for server's account.
181
    # @param challenge_xdr [String] SEP0010 challenge transaction in base64.
182
    # @param signers [{String => Integer}] The signers of client account.
183
    # @param threshold [Integer] The medThreshold on the client account.
184
    #
185
    # @raise InvalidSep10ChallengeError if the transaction has unrecognized signatures (only server's
186
    #   signing key and keypairs found in the `signing` argument are recognized) or total weight of
187
    #   the signers does not meet the `threshold`
188
    #
189
    # @return [<String>] subset of input signers who have signed `challenge_xdr`
190
    def self.verify_challenge_tx_threshold(server:, challenge_xdr:, signers:, threshold:)
1✔
191
      signers_found = verify_challenge_tx_signers(
9✔
192
        server: server, challenge_xdr: challenge_xdr, signers: signers.keys
193
      )
194

195
      total_weight = signers.values_at(*signers_found).sum
4✔
196

197
      if total_weight < threshold
4✔
198
        raise InvalidSep10ChallengeError, "signers with weight #{total_weight} do not meet threshold #{threshold}."
1✔
199
      end
200

201
      signers_found
3✔
202
    end
203

204
    # Verifies that for a SEP 10 challenge transaction all signatures on the transaction are accounted for.
205
    #
206
    # A transaction is verified if it is signed by the server account, and all other signatures match a signer
207
    # that has been provided as an argument. Additional signers can be provided that do not have a signature,
208
    # but all signatures must be matched to a signer for verification to succeed.
209
    #
210
    # If verification succeeds a list of signers that were found is returned, excluding the server account ID.
211
    #
212
    # @param server [Stellar::Keypair]  server's signing key
213
    # @param challenge_xdr [String] SEP0010 transaction challenge transaction in base64.
214
    # @param signers [<String>] The signers of client account.
215
    #
216
    # @raise InvalidSep10ChallengeError one or more signatures in the transaction are not identifiable
217
    #   as the server account or one of the signers provided in the arguments
218
    #
219
    # @return [<String>] subset of input signers who have signed `challenge_xdr`
220
    def self.verify_challenge_tx_signers(server:, challenge_xdr:, signers:)
1✔
221
      raise InvalidSep10ChallengeError, "no signers provided" if signers.empty?
23✔
222

223
      te, _ = read_challenge_tx(server: server, challenge_xdr: challenge_xdr)
21✔
224

225
      # ignore non-G signers and server's own address
226
      client_signers = signers.select { |s| s =~ /G[A-Z0-9]{55}/ && s != server.address }.to_set
68✔
227
      raise InvalidSep10ChallengeError, "at least one regular signer must be provided" if client_signers.empty?
17✔
228

229
      client_domain_account_address = extract_client_domain_account(te.tx)
16✔
230
      client_signers.add(client_domain_account_address) if client_domain_account_address.present?
16✔
231

232
      # verify all signatures in one pass
233
      client_signers.add(server.address)
16✔
234
      signers_found = verify_tx_signatures(tx_envelope: te, signers: client_signers)
16✔
235

236
      # ensure server signed transaction and remove it
237
      unless signers_found.delete?(server.address)
16!
238
        raise InvalidSep10ChallengeError, "Transaction not signed by server: #{keypair}"
×
239
      end
240

241
      # Confirm we matched signatures to the client signers.
242
      if signers_found.empty?
16✔
243
        raise InvalidSep10ChallengeError, "Transaction not signed by any client signer."
1✔
244
      end
245

246
      # Confirm all signatures were consumed by a signer.
247
      if signers_found.size != te.signatures.length - 1
15✔
248
        raise InvalidSep10ChallengeError, "Transaction has unrecognized signatures."
4✔
249
      end
250

251
      if client_domain_account_address.present? && !signers_found.include?(client_domain_account_address)
11✔
252
        raise InvalidSep10ChallengeError, "Transaction not signed by client domain account."
1✔
253
      end
254

255
      signers_found
10✔
256
    end
257

258
    # Verifies every signer passed matches a signature on the transaction exactly once,
259
    # returning a list of unique signers that were found to have signed the transaction.
260
    #
261
    # @param tx_envelope [Stellar::TransactionEnvelope] SEP0010 transaction challenge transaction envelope.
262
    # @param signers [<String>] The signers of client account.
263
    #
264
    # @return [Set<Stellar::KeyPair>]
265
    def self.verify_tx_signatures(tx_envelope:, signers:)
1✔
266
      signatures = tx_envelope.signatures
19✔
267
      if signatures.empty?
19✔
268
        raise InvalidSep10ChallengeError, "Transaction has no signatures."
1✔
269
      end
270

271
      tx_hash = tx_envelope.tx.hash
18✔
272
      to_keypair = Stellar::DSL.method(:KeyPair)
18✔
273
      keys_by_hint = signers.map(&to_keypair).index_by(&:signature_hint)
18✔
274

275
      signatures.each_with_object(Set.new) do |sig, result|
18✔
276
        key = keys_by_hint.delete(sig.hint)
67✔
277
        result.add(key.address) if key&.verify(sig.signature, tx_hash)
67✔
278
      end
279
    end
280

281
    # Verifies if a Stellar::TransactionEnvelope was signed by the given Stellar::KeyPair
282
    #
283
    # @example
284
    #   Stellar::SEP10.verify_tx_signed_by(tx_envelope: envelope, keypair: keypair)
285
    #
286
    # @param tx_envelope [Stellar::TransactionEnvelope]
287
    # @param keypair [Stellar::KeyPair]
288
    #
289
    # @return [Boolean]
290
    def self.verify_tx_signed_by(tx_envelope:, keypair:)
1✔
291
      tx_hash = tx_envelope.tx.hash
31✔
292
      tx_envelope.signatures.any? do |sig|
31✔
293
        next if sig.hint != keypair.signature_hint
33✔
294

295
        keypair.verify(sig.signature, tx_hash)
25✔
296
      end
297
    end
298

299
    def self.extract_client_domain_account(transaction)
1✔
300
      client_domain_account_op =
301
        transaction
16✔
302
          .operations
303
          .find { |op| op.body.value.data_name == "client_domain" }
18✔
304

305
      return if client_domain_account_op.blank?
16✔
306

307
      Util::StrKey.encode_muxed_account(client_domain_account_op.source_account)
2✔
308
    end
309
  end
310
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc