sqrl-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,475 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Client
5
+ module Conformance
6
+ # Runs the conformance cases against one site. Every case is self-contained: it
7
+ # creates its own identities and its own account, so cases can run individually
8
+ # (the RSpec shared examples do that) or all together with {#run}.
9
+ class Runner
10
+ # Feature flags with their defaults. Turn a flag off when the site does not
11
+ # support that behaviour, so the corresponding cases are skipped instead of failed.
12
+ DEFAULT_FEATURES = {
13
+ cps: true, # the server sends url= and the landing page logs the browser in
14
+ poll: true, # a status URL is discoverable and reports the login
15
+ rekey: true,
16
+ remove: true,
17
+ ask: false, # the site poses an ask= (needs an ask handler policy of "button 1")
18
+ sin: false, # the site sends sin=
19
+ alt_id: true,
20
+ negative: true, # malformed / replayed requests
21
+ cps_single_use: true, # the CPS url= may be used once only
22
+ concurrency: true
23
+ }.freeze
24
+
25
+ # Case name => [group, feature it needs (or nil)]
26
+ CASES = {
27
+ create_account: ["happy", nil],
28
+ login_existing: ["happy", nil],
29
+ login_cps_landing: ["happy", :cps],
30
+ login_without_cps_poll: ["happy", :poll],
31
+ disable_then_login_refused: ["account", nil],
32
+ enable_with_iuk: ["account", nil],
33
+ remove_then_recreate: ["account", :remove],
34
+ rekey_moves_account: ["rekey", :rekey],
35
+ superseded_identity_refused: ["rekey", :rekey],
36
+ ask_and_button: ["options", :ask],
37
+ sin_and_ins: ["options", :sin],
38
+ alt_id_is_distinct: ["options", :alt_id],
39
+ nut_replay_rejected: ["negative", :negative],
40
+ unknown_nut_is_transient: ["negative", :negative],
41
+ tampered_client_rejected: ["negative", :negative],
42
+ wrong_server_echo_rejected: ["negative", :negative],
43
+ bad_signature_rejected: ["negative", :negative],
44
+ ident_without_lock_rejected: ["negative", :negative],
45
+ cps_token_single_use: ["negative", :cps_single_use],
46
+ callback_without_token_rejected: ["negative", :cps_single_use],
47
+ undefined_tif_bit_aborts_client: ["negative", :negative],
48
+ transient_recovery: ["robustness", nil],
49
+ concurrent_logins: ["robustness", :concurrency]
50
+ }.freeze
51
+
52
+ attr_reader :site, :features, :report
53
+
54
+ # @param login_url [String] the site's login page
55
+ # @param identity_factory [#call] returns a fresh {Identity} with a known IUK
56
+ # @param transport_factory [#call, nil] returns a fresh {Transport} ("browser")
57
+ # @param features [Hash] overrides of {DEFAULT_FEATURES}
58
+ # @param site [Site, nil] custom site adapter
59
+ # @param success_matcher [#call, nil] see {Site#initialize}
60
+ # @param ask_button [Integer] button to answer an +ask+ with
61
+ # @param only [Array<Symbol>, nil] run only these cases
62
+ # @param insecure [Boolean] accept the loopback-only +qrl://+ / plain-http testing
63
+ # mode (for example the browser extension's test server); off by default
64
+ def initialize(login_url:, identity_factory: -> { Identity.ephemeral }, transport_factory: nil, features: {},
65
+ site: nil, success_matcher: nil, ask_button: 1, only: nil, logger: nil, insecure: false)
66
+ @login_url = login_url
67
+ @identity_factory = identity_factory
68
+ @insecure = insecure ? true : false
69
+ @transport_factory = transport_factory ||
70
+ -> { Transport.new(logger: logger, allow_http: @insecure ? :loopback : false) }
71
+ @features = DEFAULT_FEATURES.merge(features.transform_keys(&:to_sym))
72
+ @site = site || Site.new(login_url: login_url, success_matcher: success_matcher)
73
+ @ask_button = ask_button
74
+ @only = only&.map(&:to_sym)
75
+ @logger = logger
76
+ @report = Report.new(login_url: login_url)
77
+ end
78
+
79
+ # Names of the cases {#run} would execute (skips included).
80
+ def case_names
81
+ names = CASES.keys
82
+ names &= @only if @only
83
+ names
84
+ end
85
+
86
+ # Run every case.
87
+ #
88
+ # @return [Report]
89
+ def run
90
+ case_names.each { |name| @report << run_case(name) }
91
+ @report.finish!
92
+ end
93
+
94
+ # Run one case.
95
+ #
96
+ # @return [Case]
97
+ def run_case(name)
98
+ name = name.to_sym
99
+ group, feature = CASES.fetch(name) { raise ArgumentError, "unknown conformance case #{name}" }
100
+ if feature && !@features[feature]
101
+ return Case.new(name: name, group: group, status: :skipped, message: "feature #{feature} disabled")
102
+ end
103
+
104
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
105
+ ctx = CaseContext.new
106
+ begin
107
+ send(:"case_#{name}", ctx)
108
+ Case.new(name: name, group: group, status: :passed, duration: elapsed(started), transcript: ctx.transcript)
109
+ rescue Skip => e
110
+ Case.new(name: name, group: group, status: :skipped, message: e.message, duration: elapsed(started))
111
+ rescue StandardError => e
112
+ @logger&.warn { "[conformance] #{name}: #{e.class}: #{e.message}" }
113
+ Case.new(name: name, group: group, status: :failed, message: "#{e.class}: #{e.message}",
114
+ duration: elapsed(started), transcript: ctx.transcript, error: e)
115
+ end
116
+ end
117
+
118
+ # Raised inside a case to mark it skipped.
119
+ class Skip < StandardError; end
120
+
121
+ # Raised inside a case on an unmet expectation.
122
+ class Failure < StandardError; end
123
+
124
+ # Per-case scratch state. @api private
125
+ class CaseContext
126
+ attr_accessor :transcript
127
+
128
+ def initialize
129
+ @transcript = []
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def elapsed(started)
136
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
137
+ end
138
+
139
+ def check(condition, message)
140
+ raise Failure, message unless condition
141
+ end
142
+
143
+ def skip(message)
144
+ raise Skip, message
145
+ end
146
+
147
+ def identity = @identity_factory.call
148
+ def transport = @transport_factory.call
149
+
150
+ # Agents answer the site's ask (when the feature is on) with the configured button
151
+ # unless a case supplies its own handler.
152
+ def agent(identity, transport: self.transport, **opts)
153
+ defaults = { ask_handler: (@features[:ask] ? ->(_ask) { @ask_button } : nil), insecure: @insecure }
154
+ Agent.new(identity: identity, transport: transport, logger: @logger, **defaults.merge(opts))
155
+ end
156
+
157
+ def login(identity, ctx, **opts)
158
+ result = agent(identity, **opts).login(@login_url)
159
+ ctx.transcript.concat(result.transcript)
160
+ result
161
+ end
162
+
163
+ def expect_login_failure(reason, ctx)
164
+ yield
165
+ raise Failure, "expected the login to fail with #{reason}"
166
+ rescue LoginFailed => e
167
+ ctx.transcript << { error: e.class.name, reason: e.reason, tif: e.tif&.to_hex }
168
+ check(e.reason == reason, "expected failure reason #{reason}, got #{e.reason} (tif 0x#{e.tif&.to_hex})")
169
+ e
170
+ end
171
+
172
+ def expect_logged_in(result, what)
173
+ check(result.authenticated?, "#{what}: not authenticated (tif 0x#{result.tif&.to_hex})")
174
+ return unless @features[:cps] && result.cps_url
175
+
176
+ check(@site.success?(result.landing), "#{what}: following the CPS url= did not land on a logged-in page " \
177
+ "(HTTP #{result.landing&.status} at #{result.landing&.final_url})")
178
+ end
179
+
180
+ # ---- happy paths -------------------------------------------------------------
181
+
182
+ def case_create_account(ctx)
183
+ result = login(identity, ctx, cps: @features[:cps])
184
+ check(result.created?, "first login should create the account")
185
+ expect_logged_in(result, "account creation")
186
+ end
187
+
188
+ def case_login_existing(ctx)
189
+ id = identity
190
+ login(id, ctx, cps: @features[:cps])
191
+ result = login(id, ctx, cps: @features[:cps])
192
+ check(!result.created?, "second login must not create another account")
193
+ check(result.state.known?, "second login should be a known identity")
194
+ expect_logged_in(result, "login")
195
+ end
196
+
197
+ def case_login_cps_landing(ctx)
198
+ id = identity
199
+ result = login(id, ctx, cps: true)
200
+ check(!result.cps_url.nil?, "the server sent no url= although cps was requested")
201
+ check(result.cps_url.start_with?("https://", "http://"), "CPS url= is not absolute: #{result.cps_url}")
202
+ check(@site.success?(result.landing), "CPS landing is not a logged-in page (HTTP #{result.landing&.status})")
203
+ end
204
+
205
+ def case_login_without_cps_poll(ctx)
206
+ id = identity
207
+ page = @site.login_page(transport)
208
+ skip("the login page exposes no status URL") unless page.status_url
209
+ browser = transport
210
+ page = @site.login_page(browser)
211
+ result = agent(id, transport: browser, cps: false).login(page.sqrl_url)
212
+ ctx.transcript.concat(result.transcript)
213
+ check(result.authenticated?, "ident without cps was not accepted")
214
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10
215
+ status = nil
216
+ loop do
217
+ status = browser.get(page.status_url, { "Accept" => "application/json" }).json || {}
218
+ break if status["authenticated"] || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
219
+
220
+ sleep 0.3
221
+ end
222
+ check(status["authenticated"], "status URL never reported the login: #{status.inspect}")
223
+ return unless status["next"]
224
+
225
+ landing = browser.get(URI.join(page.status_url, status["next"]).to_s)
226
+ check(@site.success?(landing),
227
+ "following the poll's next URL did not log the browser in (HTTP #{landing.status})")
228
+ end
229
+
230
+ # ---- account commands ------------------------------------------------------------
231
+
232
+ def case_disable_then_login_refused(ctx)
233
+ id = identity
234
+ login(id, ctx, cps: false, poll: false)
235
+ d = agent(id).disable(@login_url)
236
+ ctx.transcript.concat(d.transcript)
237
+ check(d.tif.sqrl_disabled?, "disable did not set SQRL_DISABLED")
238
+ q = agent(id).query(@login_url)
239
+ check(q.state.known? && q.state.disabled?, "query after disable should report known + disabled")
240
+ expect_login_failure(:disabled, ctx) { login(id, ctx, cps: false, poll: false) }
241
+ end
242
+
243
+ def case_enable_with_iuk(ctx)
244
+ id = identity
245
+ skip("identity factory must produce identities with a known IUK") unless id.iuk?
246
+ login(id, ctx, cps: false, poll: false)
247
+ agent(id).disable(@login_url)
248
+ e = agent(id, cps: @features[:cps]).enable(@login_url)
249
+ ctx.transcript.concat(e.transcript)
250
+ expect_logged_in(e, "enable")
251
+ check(!e.tif.sqrl_disabled?, "enable left SQRL_DISABLED set")
252
+ result = login(id, ctx, cps: false, poll: false)
253
+ check(result.authenticated? && !result.state.disabled?, "login after enable failed")
254
+ end
255
+
256
+ def case_remove_then_recreate(ctx)
257
+ id = identity
258
+ skip("identity factory must produce identities with a known IUK") unless id.iuk?
259
+ login(id, ctx, cps: false, poll: false)
260
+ r = agent(id).remove(@login_url)
261
+ ctx.transcript.concat(r.transcript)
262
+ check(!r.tif.id_match?, "remove left ID_MATCH set")
263
+ q = agent(id).query(@login_url)
264
+ check(q.state.unknown?, "identity still known after remove")
265
+ again = login(id, ctx, cps: false, poll: false)
266
+ check(again.created?, "login after remove should create the account again")
267
+ end
268
+
269
+ # ---- re-keying ---------------------------------------------------------------------
270
+
271
+ def case_rekey_moves_account(ctx)
272
+ id = identity
273
+ skip("identity factory must produce identities with a known IUK") unless id.iuk?
274
+ first = login(id, ctx, cps: false, poll: false)
275
+ id.rekey!
276
+ result = login(id, ctx, cps: @features[:cps])
277
+ check(result.rekeyed?, "login with the re-keyed identity did not re-key the account")
278
+ check(result.idk != first.idk, "idk did not change after re-key")
279
+ expect_logged_in(result, "re-key login")
280
+ later = login(id, ctx, cps: false, poll: false)
281
+ check(later.state.known? && !later.rekeyed?, "the re-keyed identity should now be known directly")
282
+ end
283
+
284
+ def case_superseded_identity_refused(ctx)
285
+ id = identity
286
+ skip("identity factory must produce identities with a known IUK") unless id.iuk?
287
+ login(id, ctx, cps: false, poll: false)
288
+ id.rekey!
289
+ login(id, ctx, cps: false, poll: false)
290
+ old = id.previous_identity
291
+ expect_login_failure(:superseded, ctx) { login(old, ctx, cps: false, poll: false) }
292
+ end
293
+
294
+ # ---- server options --------------------------------------------------------------
295
+
296
+ def case_ask_and_button(ctx)
297
+ id = identity
298
+ login(id, ctx, cps: false, poll: false, ask_handler: ->(_ask) { @ask_button })
299
+ asked = nil
300
+ result = login(id, ctx, cps: false, poll: false, ask_handler: lambda { |ask|
301
+ asked = ask
302
+ @ask_button
303
+ })
304
+ check(!asked.nil?, "the server posed no ask= to a known identity")
305
+ check(!asked.message.to_s.empty?, "ask carried no message")
306
+ check(result.authenticated?, "login with the ask answered was not accepted")
307
+ check(result.transcript.last[:client_text].include?("btn=#{@ask_button}"), "btn= was not sent with ident")
308
+ begin
309
+ login(id, ctx, cps: false, poll: false, ask_handler: nil)
310
+ raise Failure, "login without an ask handler should raise AskRequired"
311
+ rescue AskRequired
312
+ nil
313
+ end
314
+ end
315
+
316
+ def case_sin_and_ins(ctx)
317
+ id = identity
318
+ first = login(id, ctx, cps: false, poll: false)
319
+ check(first.transcript.last[:response].key?("sin") || first.transcript.first[:response].key?("sin"),
320
+ "the server sent no sin=")
321
+ check(first.transcript.last[:client_text].include?("ins="), "ins= was not sent")
322
+ second = login(id, ctx, cps: false, poll: false)
323
+ check(second.authenticated?, "second login with ins= failed")
324
+ end
325
+
326
+ def case_alt_id_is_distinct(ctx)
327
+ id = identity
328
+ a = login(id, ctx, cps: false, poll: false)
329
+ b = login(id, ctx, cps: false, poll: false, alt_id: "conformance")
330
+ check(a.idk != b.idk, "Alt-ID produced the same idk")
331
+ check(b.created?, "Alt-ID login should create a separate account")
332
+ end
333
+
334
+ # ---- negative ----------------------------------------------------------------------
335
+
336
+ # A raw session against a fresh login page: [session, transport, page].
337
+ def raw_session(id, options: ClientOptions.new)
338
+ browser = transport
339
+ page = @site.login_page(browser)
340
+ [Session.new(id.unlocked, page.sqrl_url, options: options, insecure: @insecure), browser, page]
341
+ end
342
+
343
+ def raw_post(browser, url, body, ctx, label)
344
+ response = browser.post_form(url, body, { "Accept" => "text/plain" })
345
+ check(response.success?, "#{label}: SQRL endpoint answered HTTP #{response.status}")
346
+ parsed = ServerResponse.parse(response.body.strip)
347
+ ctx.transcript << { label: label, tif: parsed.tif.to_hex, response: parsed.params.to_h }
348
+ parsed
349
+ end
350
+
351
+ def case_nut_replay_rejected(ctx)
352
+ session, browser, = raw_session(identity)
353
+ req = session.query
354
+ first = raw_post(browser, req.url, req.body, ctx, "query")
355
+ check(!first.tif.error?, "the first query failed")
356
+ replay = raw_post(browser, req.url, req.body, ctx, "replay")
357
+ check(replay.tif.error?, "a replayed request was accepted (tif 0x#{replay.tif.to_hex})")
358
+ check(!replay.tif.id_match? || replay.tif.command_failed?, "replay must not succeed")
359
+ end
360
+
361
+ def case_unknown_nut_is_transient(ctx)
362
+ id = identity
363
+ browser = transport
364
+ page = @site.login_page(browser)
365
+ bogus = page.sqrl_url.sub(/nut=[^&]+/, "nut=conformance-unknown-nut")
366
+ session = Session.new(id.unlocked, bogus, insecure: @insecure)
367
+ req = session.query
368
+ resp = raw_post(browser, req.url, req.body, ctx, "unknown nut")
369
+ check(resp.tif.transient_error? || resp.tif.client_failure?,
370
+ "unknown nut should be a transient error (or client failure), got 0x#{resp.tif.to_hex}")
371
+ end
372
+
373
+ def case_tampered_client_rejected(ctx)
374
+ session, browser, = raw_session(identity)
375
+ req = session.query
376
+ client = req.body[/client=([^&]+)/, 1]
377
+ flipped = client.dup
378
+ flipped[5] = flipped[5] == "A" ? "B" : "A"
379
+ resp = raw_post(browser, req.url, req.body.sub(client, flipped), ctx, "tampered client")
380
+ check(resp.tif.client_failure? && resp.tif.command_failed?,
381
+ "tampered client= accepted (0x#{resp.tif.to_hex})")
382
+ end
383
+
384
+ def case_wrong_server_echo_rejected(ctx)
385
+ id = identity
386
+ session, browser, page = raw_session(id)
387
+ other = @site.login_page(transport)
388
+ # sign a request whose server= is a different (valid) login URL
389
+ foreign = Session.new(id.unlocked, other.sqrl_url, insecure: @insecure).query
390
+ url = page.sqrl_url.sub(/\As?qrl/, page.sqrl_url.start_with?("qrl") ? "http" : "https")
391
+ resp = raw_post(browser, url, foreign.body, ctx, "wrong server echo")
392
+ check(resp.tif.client_failure? && resp.tif.command_failed?,
393
+ "mismatched server= accepted (0x#{resp.tif.to_hex})")
394
+ _ = session
395
+ end
396
+
397
+ def case_bad_signature_rejected(ctx)
398
+ id = identity
399
+ session, browser, = raw_session(id)
400
+ req = session.query
401
+ ids = req.body[/ids=([^&]+)/, 1]
402
+ other = Util.b64u_encode(Crypto.ed25519_sign(Util.random_bytes(32), "x"))
403
+ resp = raw_post(browser, req.url, req.body.sub("ids=#{ids}", "ids=#{other}"), ctx, "bad ids")
404
+ check(resp.tif.client_failure? && resp.tif.command_failed?, "bad ids= accepted (0x#{resp.tif.to_hex})")
405
+ end
406
+
407
+ def case_ident_without_lock_rejected(ctx)
408
+ id = identity
409
+ session, browser, = raw_session(id)
410
+ req = session.query
411
+ session.feed(raw_post(browser, req.url, req.body, ctx, "query").raw)
412
+ skip("identity already known on this site") unless session.state.unknown?
413
+ # an ident for a new account without suk/vuk must not create it
414
+ ident = session.ident
415
+ stripped_client = Protocol.decode_text(ident.body[/client=([^&]+)/, 1]).gsub(/^(suk|vuk)=.*\r\n/, "")
416
+ client_b64 = Util.b64u_encode(stripped_client.b)
417
+ server = ident.body[/server=([^&]+)/, 1]
418
+ sig = Util.b64u_encode(Crypto.ed25519_sign(id.unlocked.site_seed(session.url.site_key_bytes),
419
+ (client_b64 + server).b))
420
+ resp = raw_post(browser, ident.url, "client=#{client_b64}&server=#{server}&ids=#{sig}", ctx,
421
+ "ident without lock")
422
+ check(resp.tif.command_failed?, "ident without suk/vuk created an account (0x#{resp.tif.to_hex})")
423
+ check(!resp.tif.id_match?, "ident without suk/vuk reported ID_MATCH")
424
+ end
425
+
426
+ def case_cps_token_single_use(ctx)
427
+ result = login(identity, ctx, cps: true)
428
+ check(result.cps_url, "no CPS url= received")
429
+ check(@site.success?(result.landing), "first use of the CPS URL did not log in")
430
+ second = transport.get(result.cps_url)
431
+ check(!@site.success?(second), "the CPS URL logged a second, cookie-less client in (HTTP #{second.status})")
432
+ end
433
+
434
+ def case_callback_without_token_rejected(ctx)
435
+ result = login(identity, ctx, cps: true)
436
+ check(result.cps_url, "no CPS url= received")
437
+ bare = transport.get(@site.callback_without_token(result.cps_url))
438
+ check(!@site.success?(bare), "the CPS callback without a token logged in (HTTP #{bare.status})")
439
+ end
440
+
441
+ def case_undefined_tif_bit_aborts_client(_ctx)
442
+ body = Util.b64u_encode("ver=1\r\nnut=x\r\ntif=400\r\nqry=/cli\r\n")
443
+ session = Session.new(identity.unlocked, "sqrl://example.com/cli?nut=x")
444
+ begin
445
+ session.feed(body)
446
+ raise Failure, "a response with TIF 0x400 was accepted"
447
+ rescue ProtocolError, FormatError
448
+ nil
449
+ end
450
+ end
451
+
452
+ # ---- robustness --------------------------------------------------------------------
453
+
454
+ def case_transient_recovery(ctx)
455
+ id = identity
456
+ browser = transport
457
+ page = @site.login_page(browser)
458
+ bogus = page.sqrl_url.sub(/nut=[^&]+/, "nut=conformance-stale")
459
+ result = agent(id, transport: browser, cps: false, poll: false).login(bogus)
460
+ ctx.transcript.concat(result.transcript)
461
+ check(result.tif_history.first.transient_error?, "the stale nut did not produce a transient error")
462
+ check(result.authenticated?, "login did not recover from the transient error")
463
+ end
464
+
465
+ def case_concurrent_logins(ctx)
466
+ results = Array.new(4) do
467
+ Thread.new { login(identity, ctx, cps: false, poll: false) }
468
+ end.map(&:value)
469
+ check(results.all?(&:authenticated?), "a concurrent login failed")
470
+ check(results.map(&:idk).uniq.size == results.size, "concurrent logins shared an idk")
471
+ end
472
+ end
473
+ end
474
+ end
475
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Client
5
+ module Conformance
6
+ # How the runner finds its way around the site under test. The default works for any
7
+ # page the {Discovery} module understands; subclass or pass your own object
8
+ # responding to +login_page(transport)+ for sites with a different layout (the
9
+ # interop suite does this for the Rust reference server).
10
+ class Site
11
+ # What a login page gives us.
12
+ Page = Struct.new(:sqrl_url, :status_url, :page_url, :html, keyword_init: true)
13
+
14
+ attr_reader :login_url
15
+
16
+ # @param login_url [String]
17
+ # @param success_matcher [#call, nil] +(Transport::Response, Page) -> Boolean+
18
+ # deciding whether a landing response means "logged in"; defaults to a 2xx
19
+ # response whose final URL is not the login page
20
+ def initialize(login_url:, success_matcher: nil)
21
+ @login_url = login_url
22
+ @success_matcher = success_matcher
23
+ end
24
+
25
+ # Fetch the login page with +transport+ (so its cookies become the "browser").
26
+ #
27
+ # @return [Page]
28
+ def login_page(transport)
29
+ response = transport.get(@login_url)
30
+ unless response.success?
31
+ raise TransportError.new("login page answered #{response.status}", url: @login_url, status: response.status)
32
+ end
33
+
34
+ found = Discovery.find(response.body, base_url: response.final_url)
35
+ Page.new(sqrl_url: found.sqrl_url, status_url: found.status_url, page_url: response.final_url,
36
+ html: response.body)
37
+ end
38
+
39
+ # Whether +response+ (after following the CPS URL or the poll's +next+) shows a
40
+ # logged-in session.
41
+ def success?(response, page = nil)
42
+ return @success_matcher.call(response, page) if @success_matcher
43
+ return false if response.nil? || !response.success?
44
+
45
+ strip = ->(u) { u.to_s.sub(/[?#].*\z/, "").chomp("/") }
46
+ strip.call(response.final_url) != strip.call(@login_url)
47
+ end
48
+
49
+ # The CPS callback path without its token, for the "callback without token" case.
50
+ def callback_without_token(cps_url)
51
+ cps_url.sub(/\?.*\z/, "")
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "conformance/report"
4
+ require_relative "conformance/site"
5
+ require_relative "conformance/runner"
6
+
7
+ module SQRL
8
+ module Client
9
+ # A behavioural test-suite for SQRL servers, driven through the bot client. Point it at
10
+ # a login page and it exercises account creation, login with and without CPS, the
11
+ # account commands, re-keying, +ask+/+sin+ (when the site uses them) and a battery of
12
+ # negative protocol cases.
13
+ #
14
+ # report = SQRL::Client::Conformance::Runner.new(login_url: "https://example.com/login").run
15
+ # puts report
16
+ # report.passed? # => true
17
+ #
18
+ # In RSpec: +require "sqrl/client/conformance/rspec"+ and
19
+ # +it_behaves_like "a SQRL server"+ with +let(:login_url)+.
20
+ module Conformance
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module SQRL
6
+ module Client
7
+ # Finds the SQRL login URL on a login page.
8
+ #
9
+ # Search order:
10
+ # 1. +data-sqrl-url+ on the +[data-sqrl-login]+ element rendered by the sqrl-rack /
11
+ # sqrl-rails helpers (plus +data-sqrl-status+ for the poll endpoint);
12
+ # 2. +a[data-sqrl-link]+;
13
+ # 3. any +href+ that is a +sqrl://+ URL (or +qrl://+ on a loopback host);
14
+ # 4. +href="http://localhost:25519/<base64url>"+ CPS-style links, decoded back to the
15
+ # +sqrl://+ URL they carry;
16
+ # 5. a bare +sqrl://+ URL anywhere in the page text.
17
+ #
18
+ # A tolerant scanner is used by default; Nokogiri is used for attribute extraction
19
+ # when it is available.
20
+ module Discovery
21
+ # What was found on a page.
22
+ Result = Struct.new(:sqrl_url, :status_url, :source, :page_url, keyword_init: true) do
23
+ # @return [String] a short description with secret material redacted
24
+ def inspect
25
+ "#<SQRL::Client::Discovery::Result #{sqrl_url} (#{source})>"
26
+ end
27
+ end
28
+
29
+ # A +localhost:25519/<base64url>+ CPS-style link.
30
+ CPS_LINK = %r{\Ahttps?://(?:localhost|127\.0\.0\.1):25519/([A-Za-z0-9_-]+)\z}i
31
+ # A bare +sqrl://+ (or +qrl://+) URL in text.
32
+ SQRL_URL = %r{\b(s?qrl://[^\s"'<>\\]+)}i
33
+ # Builds a regexp matching an HTML attribute by name.
34
+ ATTR = ->(name) { /#{name}\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i }
35
+
36
+ module_function
37
+
38
+ # @param html [String] the page
39
+ # @param base_url [String, nil] the page's URL, for resolving relative status URLs
40
+ # @return [Result]
41
+ # @raise [DiscoveryError]
42
+ def find(html, base_url: nil)
43
+ html = html.to_s
44
+ direct = from_target(html.strip)
45
+ return direct if direct && html.strip.length < 2048 && !html.include?("<")
46
+
47
+ status = status_url(html, base_url)
48
+ candidates = []
49
+ candidates << [attribute_value(html, "data-sqrl-url"), "data-sqrl-url"]
50
+ candidates << [attribute_on(html, /<a\b[^>]*data-sqrl-link[^>]*>/i, "href"), "a[data-sqrl-link]"]
51
+ hrefs(html).each { |h| candidates << [h, "href"] }
52
+ html.scan(SQRL_URL) { |m| candidates << [m[0], "text"] }
53
+
54
+ candidates.each do |(value, source)|
55
+ next if value.nil? || value.empty?
56
+
57
+ url = normalize(unescape(value))
58
+ next unless url
59
+
60
+ return Result.new(sqrl_url: url, status_url: status, source: source, page_url: base_url)
61
+ end
62
+ raise DiscoveryError, "no sqrl:// login link found#{" on #{base_url}" if base_url}"
63
+ end
64
+
65
+ # Turn a target string that is itself a login link into a Result: a +sqrl://+ /
66
+ # +qrl://+ URL or a +localhost:25519/<b64u>+ CPS link. Returns nil for anything else
67
+ # (a page URL to fetch, or HTML).
68
+ def from_target(target)
69
+ url = normalize(target.to_s.strip)
70
+ url && Result.new(sqrl_url: url, source: "target")
71
+ end
72
+
73
+ # Validate / decode one candidate into a +sqrl://+ URL string, or nil.
74
+ def normalize(value)
75
+ value = value.to_s.strip
76
+ return nil if value.empty?
77
+
78
+ if (m = value.match(CPS_LINK))
79
+ decoded = Util.b64u_decode(m[1]).force_encoding(Encoding::UTF_8)
80
+ return nil unless decoded.valid_encoding?
81
+
82
+ value = decoded.strip
83
+ end
84
+ return nil unless value.match?(%r{\As?qrl://}i)
85
+
86
+ URL.parse(value).original
87
+ rescue SQRL::Error
88
+ nil
89
+ end
90
+
91
+ # @return [String, nil] the +data-sqrl-status+ URL, resolved against the page
92
+ def status_url(html, base_url)
93
+ raw = attribute_value(html, "data-sqrl-status")
94
+ return nil if raw.nil? || raw.empty?
95
+
96
+ raw = unescape(raw)
97
+ base_url ? URI.join(base_url, raw).to_s : raw
98
+ rescue URI::Error
99
+ nil
100
+ end
101
+
102
+ # @return [Array<String>] every +href+ value in the page
103
+ def hrefs(html)
104
+ html.scan(/<a\b[^>]*>/i).filter_map { |tag| attribute_in_tag(tag, "href") }
105
+ end
106
+
107
+ # @return [String, nil] a matched attribute value, unquoted
108
+ def attribute_value(html, name)
109
+ tag = html[/<[^>]*\b#{Regexp.escape(name)}\s*=[^>]*>/i]
110
+ tag && attribute_in_tag(tag, name)
111
+ end
112
+
113
+ # @return [String, nil] an attribute's value on the first tag carrying another attribute
114
+ def attribute_on(html, tag_regex, name)
115
+ tag = html[tag_regex]
116
+ tag && attribute_in_tag(tag, name)
117
+ end
118
+
119
+ # @return [String, nil] an attribute's value inside one tag
120
+ def attribute_in_tag(tag, name)
121
+ m = tag.match(ATTR.call(Regexp.escape(name)))
122
+ m && (m[1] || m[2] || m[3])
123
+ end
124
+
125
+ # @return [String] HTML entities decoded
126
+ def unescape(str)
127
+ str.gsub("&amp;", "&").gsub("&quot;", '"').gsub("&#39;", "'").gsub("&lt;", "<").gsub("&gt;", ">")
128
+ end
129
+ end
130
+ end
131
+ end