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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +21 -0
- data/README.md +154 -0
- data/exe/sqrl-client +7 -0
- data/lib/sqrl/client/agent.rb +399 -0
- data/lib/sqrl/client/capybara.rb +52 -0
- data/lib/sqrl/client/cli.rb +409 -0
- data/lib/sqrl/client/conformance/report.rb +100 -0
- data/lib/sqrl/client/conformance/rspec.rb +30 -0
- data/lib/sqrl/client/conformance/runner.rb +475 -0
- data/lib/sqrl/client/conformance/site.rb +56 -0
- data/lib/sqrl/client/conformance.rb +23 -0
- data/lib/sqrl/client/discovery.rb +131 -0
- data/lib/sqrl/client/errors.rb +52 -0
- data/lib/sqrl/client/identity.rb +229 -0
- data/lib/sqrl/client/transport.rb +189 -0
- data/lib/sqrl/client.rb +28 -0
- metadata +94 -0
|
@@ -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("&", "&").gsub(""", '"').gsub("'", "'").gsub("<", "<").gsub(">", ">")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|