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,409 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
require "io/console"
|
|
6
|
+
|
|
7
|
+
module SQRL
|
|
8
|
+
module Client
|
|
9
|
+
# The +sqrl-client+ command-line tool.
|
|
10
|
+
#
|
|
11
|
+
# sqrl-client identity create --out bot.sqrl
|
|
12
|
+
# sqrl-client login https://example.com/login --identity bot.sqrl --cookies-out jar.txt
|
|
13
|
+
# sqrl-client conformance https://example.com/login --report junit.xml
|
|
14
|
+
#
|
|
15
|
+
# Exit codes: 0 success, 1 protocol failure (the site refused), 2 transport failure,
|
|
16
|
+
# 3 usage error.
|
|
17
|
+
class CLI
|
|
18
|
+
# Exit status: success.
|
|
19
|
+
EXIT_OK = 0
|
|
20
|
+
# Exit status: the server refused (a TIF failure) or the identity could not be used.
|
|
21
|
+
EXIT_PROTOCOL = 1
|
|
22
|
+
# Exit status: network or TLS failure.
|
|
23
|
+
EXIT_TRANSPORT = 2
|
|
24
|
+
# Exit status: bad arguments.
|
|
25
|
+
EXIT_USAGE = 3
|
|
26
|
+
|
|
27
|
+
# Help text.
|
|
28
|
+
USAGE = <<~TEXT
|
|
29
|
+
Usage: sqrl-client <command> [options]
|
|
30
|
+
|
|
31
|
+
Commands:
|
|
32
|
+
identity create --out FILE [--password PW | --password-file F] [--pw-seconds N] [--rescue-seconds N]
|
|
33
|
+
identity import --textual FILE --rescue-code CODE --out FILE [--password PW]
|
|
34
|
+
identity show --identity FILE [--password PW] [--json]
|
|
35
|
+
identity export --identity FILE [--password PW] --format s4-text|textual|binary [--out FILE]
|
|
36
|
+
identity rekey --identity FILE --password PW --rescue-code CODE [--out FILE] [--rescue-seconds N]
|
|
37
|
+
identity change-password --identity FILE --password OLD --new-password NEW [--pw-seconds N] [--out FILE]
|
|
38
|
+
identity previous --identity FILE [--password PW] list previous (superseded) identities
|
|
39
|
+
login URL perform a SQRL login (creates the account if the site does not know you)
|
|
40
|
+
query URL ask the site whether it knows the identity
|
|
41
|
+
disable URL disable SQRL login for the account
|
|
42
|
+
enable URL re-enable it (needs --rescue-code or an --iuk-hex identity)
|
|
43
|
+
remove URL remove the SQRL association (same requirement)
|
|
44
|
+
conformance URL run the server conformance suite against a login page
|
|
45
|
+
version
|
|
46
|
+
|
|
47
|
+
Identity options (login/query/disable/enable/remove):
|
|
48
|
+
--identity FILE S4 identity file (or set SQRL_IDENTITY_FILE)
|
|
49
|
+
--password PW password (or SQRL_PASSWORD / --password-file F / SQRL_PASSWORD_FILE)
|
|
50
|
+
--password-file F
|
|
51
|
+
--rescue-code CODE 24-digit rescue code (or SQRL_RESCUE_CODE), for enable/remove
|
|
52
|
+
--iuk-hex HEX ephemeral identity from a raw 64-hex IUK (or SQRL_IUK_HEX)
|
|
53
|
+
|
|
54
|
+
Login options:
|
|
55
|
+
--no-cps do not request a Client Provided Session
|
|
56
|
+
--alt-id ID use an Alt-ID
|
|
57
|
+
--refuse-unknown fail instead of creating an account on an unknown site
|
|
58
|
+
--ask N answer a server ask= with button N (0, 1 or 2)
|
|
59
|
+
--sqrlonly / --hardlock / --noiptest
|
|
60
|
+
--cookies-out FILE write the session cookies after login
|
|
61
|
+
--cookies-format F netscape (default) or json
|
|
62
|
+
--insecure loopback testing mode: accept plain http and qrl:// URLs
|
|
63
|
+
(not part of SQRL; for the extension's test server)
|
|
64
|
+
--no-verify-tls do not verify TLS certificates (testing only)
|
|
65
|
+
--ca-file FILE trust this CA bundle or self-signed certificate
|
|
66
|
+
--json machine-readable output
|
|
67
|
+
-v, --verbose protocol trace on stderr
|
|
68
|
+
|
|
69
|
+
Conformance options:
|
|
70
|
+
--report FILE write a JUnit XML report
|
|
71
|
+
--feature NAME=on|off toggle a feature (cps, poll, rekey, remove, ask, sin, alt_id, negative, cps_single_use, concurrency)
|
|
72
|
+
--only CASE[,CASE] run only these cases
|
|
73
|
+
--ask-button N button to answer asks with (default 1)
|
|
74
|
+
TEXT
|
|
75
|
+
|
|
76
|
+
# Run the command line.
|
|
77
|
+
# @param argv [Array<String>]
|
|
78
|
+
# @param out [IO]
|
|
79
|
+
# @param err [IO]
|
|
80
|
+
# @return [Integer] exit status
|
|
81
|
+
def self.run(argv = ARGV, out: $stdout, err: $stderr)
|
|
82
|
+
new(out: out, err: err).run(argv)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def initialize(out: $stdout, err: $stderr)
|
|
86
|
+
@out = out
|
|
87
|
+
@err = err
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Integer] exit code
|
|
91
|
+
def run(argv)
|
|
92
|
+
argv = argv.dup
|
|
93
|
+
command = argv.shift
|
|
94
|
+
case command
|
|
95
|
+
when "identity" then identity_command(argv)
|
|
96
|
+
when "login", "query", "disable", "enable", "remove" then protocol_command(command.to_sym, argv)
|
|
97
|
+
when "conformance" then conformance_command(argv)
|
|
98
|
+
when "version", "--version", "-V"
|
|
99
|
+
@out.puts "sqrl-client #{SQRL::VERSION}"
|
|
100
|
+
EXIT_OK
|
|
101
|
+
when nil, "help", "--help", "-h"
|
|
102
|
+
@out.puts USAGE
|
|
103
|
+
command.nil? ? EXIT_USAGE : EXIT_OK
|
|
104
|
+
else
|
|
105
|
+
@err.puts "unknown command #{command.inspect}\n\n#{USAGE}"
|
|
106
|
+
EXIT_USAGE
|
|
107
|
+
end
|
|
108
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
109
|
+
@err.puts "error: #{e.message}"
|
|
110
|
+
EXIT_USAGE
|
|
111
|
+
rescue TransportError => e
|
|
112
|
+
@err.puts "transport error: #{e.message}"
|
|
113
|
+
EXIT_TRANSPORT
|
|
114
|
+
rescue AskRequired => e
|
|
115
|
+
@err.puts "#{e.message} (use --ask N)"
|
|
116
|
+
EXIT_PROTOCOL
|
|
117
|
+
rescue LoginFailed => e
|
|
118
|
+
@err.puts "failed (#{e.reason}, tif 0x#{e.tif&.to_hex}): #{e.message}"
|
|
119
|
+
EXIT_PROTOCOL
|
|
120
|
+
rescue SQRL::Error => e
|
|
121
|
+
@err.puts "error: #{e.message}"
|
|
122
|
+
EXIT_PROTOCOL
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
# ---- identity ------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def identity_command(argv)
|
|
130
|
+
sub = argv.shift
|
|
131
|
+
opts = { pw_seconds: nil, rescue_seconds: nil }
|
|
132
|
+
parser = OptionParser.new do |o|
|
|
133
|
+
o.on("--out FILE") { |v| opts[:out] = v }
|
|
134
|
+
o.on("--identity FILE") { |v| opts[:identity] = v }
|
|
135
|
+
o.on("--password PW") { |v| opts[:password] = v }
|
|
136
|
+
o.on("--password-file FILE") { |v| opts[:password] = File.read(v).chomp }
|
|
137
|
+
o.on("--textual FILE") { |v| opts[:textual] = File.read(v) }
|
|
138
|
+
o.on("--rescue-code CODE") { |v| opts[:rescue_code] = v }
|
|
139
|
+
o.on("--pw-seconds N", Integer) { |v| opts[:pw_seconds] = v }
|
|
140
|
+
o.on("--rescue-seconds N", Float) { |v| opts[:rescue_seconds] = v }
|
|
141
|
+
o.on("--new-password PW") { |v| opts[:new_password] = v }
|
|
142
|
+
o.on("--new-password-file FILE") { |v| opts[:new_password] = File.read(v).chomp }
|
|
143
|
+
o.on("--format FORMAT", %w[s4-text textual binary]) { |v| opts[:format] = v }
|
|
144
|
+
o.on("--json") { opts[:json] = true }
|
|
145
|
+
end
|
|
146
|
+
parser.parse!(argv)
|
|
147
|
+
|
|
148
|
+
case sub
|
|
149
|
+
when "create"
|
|
150
|
+
raise ArgumentError, "--out FILE is required" unless opts[:out]
|
|
151
|
+
|
|
152
|
+
password = opts[:password] || ENV["SQRL_PASSWORD"] || prompt_password("New identity password: ")
|
|
153
|
+
id = Identity.create(password: password, path: opts[:out], pw_seconds: opts[:pw_seconds],
|
|
154
|
+
rescue_seconds: opts[:rescue_seconds])
|
|
155
|
+
if opts[:json]
|
|
156
|
+
@out.puts JSON.generate(path: opts[:out], rescue_code: id.rescue_code, fingerprint: id.fingerprint)
|
|
157
|
+
else
|
|
158
|
+
@out.puts "Identity written to #{opts[:out]} (fingerprint #{id.fingerprint})."
|
|
159
|
+
@out.puts "Rescue code (write it down, it is shown once): #{id.rescue_code}"
|
|
160
|
+
end
|
|
161
|
+
EXIT_OK
|
|
162
|
+
when "import"
|
|
163
|
+
unless opts[:textual] && opts[:rescue_code] && opts[:out]
|
|
164
|
+
raise ArgumentError,
|
|
165
|
+
"--textual FILE, --rescue-code CODE and --out FILE are required"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
password = opts[:password] || ENV["SQRL_PASSWORD"] || prompt_password("Password for the imported identity: ")
|
|
169
|
+
id = Identity.from_textual(opts[:textual], rescue_code: opts[:rescue_code], password: password)
|
|
170
|
+
id.save(opts[:out])
|
|
171
|
+
@out.puts opts[:json] ? JSON.generate(path: opts[:out],
|
|
172
|
+
fingerprint: id.fingerprint) : "Identity written to #{opts[:out]} (fingerprint #{id.fingerprint})."
|
|
173
|
+
EXIT_OK
|
|
174
|
+
when "show"
|
|
175
|
+
id = load_identity(identity: opts[:identity], password: opts[:password])
|
|
176
|
+
info = { fingerprint: id.fingerprint, previous_identities: id.previous_count, iuk_known: id.iuk?,
|
|
177
|
+
edition: id.s4&.edition, file: id.path }
|
|
178
|
+
@out.puts opts[:json] ? JSON.generate(info) : info.map { |k, v| "#{k}: #{v}" }.join("\n")
|
|
179
|
+
EXIT_OK
|
|
180
|
+
when "export" then identity_export(opts)
|
|
181
|
+
when "rekey" then identity_rekey(opts)
|
|
182
|
+
when "change-password" then identity_change_password(opts)
|
|
183
|
+
when "previous" then identity_previous(opts)
|
|
184
|
+
else
|
|
185
|
+
raise ArgumentError, "identity needs one of: create, import, show, export, rekey, change-password, previous"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The identity file in one of its interchange forms:
|
|
190
|
+
# s4-text the SQRLDATA text form of the whole file (password + rescue blocks)
|
|
191
|
+
# textual the base56 "textual identity" (rescue blocks only; needs the rescue code to import)
|
|
192
|
+
# binary the raw sqrldata file (requires --out)
|
|
193
|
+
def identity_export(opts)
|
|
194
|
+
format = opts[:format] or raise ArgumentError, "--format s4-text|textual|binary is required"
|
|
195
|
+
id = load_identity(identity: opts[:identity], password: opts[:password])
|
|
196
|
+
raise IdentityLocked, "ephemeral identities have no file to export" unless id.s4
|
|
197
|
+
|
|
198
|
+
content =
|
|
199
|
+
case format
|
|
200
|
+
when "s4-text" then id.s4.to_text
|
|
201
|
+
when "textual" then Textual.format_for_display(Textual.encode(id.s4.rescue_blocks_to_bytes))
|
|
202
|
+
when "binary"
|
|
203
|
+
raise ArgumentError, "--out FILE is required for the binary format" unless opts[:out]
|
|
204
|
+
|
|
205
|
+
id.s4.to_bytes
|
|
206
|
+
end
|
|
207
|
+
if opts[:out]
|
|
208
|
+
File.binwrite(opts[:out], content)
|
|
209
|
+
File.chmod(0o600, opts[:out])
|
|
210
|
+
@out.puts opts[:json] ? JSON.generate(path: opts[:out], format: format,
|
|
211
|
+
bytes: content.bytesize) : "Wrote #{format} identity to #{opts[:out]}."
|
|
212
|
+
else
|
|
213
|
+
@out.puts content
|
|
214
|
+
end
|
|
215
|
+
EXIT_OK
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# A fresh Identity Unlock Key; the current one becomes the most recent previous
|
|
219
|
+
# identity, so sites re-key the account on the next login. Prints the new rescue code.
|
|
220
|
+
def identity_rekey(opts)
|
|
221
|
+
id = load_identity(identity: opts[:identity], password: opts[:password])
|
|
222
|
+
rescue_code = opts[:rescue_code] || ENV.fetch("SQRL_RESCUE_CODE", nil) or raise ArgumentError,
|
|
223
|
+
"--rescue-code CODE is required to re-key"
|
|
224
|
+
password = opts[:new_password] || opts[:password] || ENV.fetch("SQRL_PASSWORD", nil)
|
|
225
|
+
id.rekey!(password: password, rescue_code: rescue_code, rescue_seconds: opts[:rescue_seconds])
|
|
226
|
+
id.save(opts[:out]) if opts[:out]
|
|
227
|
+
if opts[:json]
|
|
228
|
+
@out.puts JSON.generate(path: id.path, fingerprint: id.fingerprint, previous_identities: id.previous_count,
|
|
229
|
+
rescue_code: id.rescue_code)
|
|
230
|
+
else
|
|
231
|
+
@out.puts "Identity re-keyed and written to #{id.path} (fingerprint #{id.fingerprint}, #{id.previous_count} previous)."
|
|
232
|
+
@out.puts "New rescue code (write it down, it is shown once): #{id.rescue_code}"
|
|
233
|
+
end
|
|
234
|
+
EXIT_OK
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Re-encrypt the password block under a new password (the rescue block is untouched).
|
|
238
|
+
def identity_change_password(opts)
|
|
239
|
+
new_password = opts[:new_password] or raise ArgumentError,
|
|
240
|
+
"--new-password PW (or --new-password-file) is required"
|
|
241
|
+
id = load_identity(identity: opts[:identity], password: opts[:password])
|
|
242
|
+
raise IdentityLocked, "ephemeral identities have no password to change" unless id.s4
|
|
243
|
+
|
|
244
|
+
settings = id.s4.settings
|
|
245
|
+
settings.pw_verify_seconds = opts[:pw_seconds] if opts[:pw_seconds]
|
|
246
|
+
id.s4.set_password(id.unlocked, new_password, settings)
|
|
247
|
+
id.save(opts[:out] || id.path)
|
|
248
|
+
@out.puts opts[:json] ? JSON.generate(path: id.path,
|
|
249
|
+
fingerprint: id.fingerprint) : "Password changed; identity written to #{id.path}."
|
|
250
|
+
EXIT_OK
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Previous (superseded) identities carried by the file, most recent first.
|
|
254
|
+
def identity_previous(opts)
|
|
255
|
+
id = load_identity(identity: opts[:identity], password: opts[:password])
|
|
256
|
+
rows = (0...id.previous_count).map { |i| { index: i, fingerprint: id.previous_identity(i).fingerprint } }
|
|
257
|
+
if opts[:json]
|
|
258
|
+
@out.puts JSON.generate(current: id.fingerprint, previous: rows)
|
|
259
|
+
else
|
|
260
|
+
@out.puts "current: #{id.fingerprint}"
|
|
261
|
+
@out.puts(rows.empty? ? "previous: none" : rows.map do |r|
|
|
262
|
+
"previous[#{r[:index]}]: #{r[:fingerprint]}"
|
|
263
|
+
end.join("\n"))
|
|
264
|
+
end
|
|
265
|
+
EXIT_OK
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# ---- login / query / disable / enable / remove ---------------------------------------
|
|
269
|
+
|
|
270
|
+
def protocol_command(command, argv)
|
|
271
|
+
opts = { cps: true, options: {}, unknown_site: :create }
|
|
272
|
+
parser = OptionParser.new do |o|
|
|
273
|
+
o.on("--identity FILE") { |v| opts[:identity] = v }
|
|
274
|
+
o.on("--password PW") { |v| opts[:password] = v }
|
|
275
|
+
o.on("--password-file FILE") { |v| opts[:password] = File.read(v).chomp }
|
|
276
|
+
o.on("--rescue-code CODE") { |v| opts[:rescue_code] = v }
|
|
277
|
+
o.on("--iuk-hex HEX") { |v| opts[:iuk_hex] = v }
|
|
278
|
+
o.on("--no-cps") { opts[:cps] = false }
|
|
279
|
+
o.on("--alt-id ID") { |v| opts[:alt_id] = v }
|
|
280
|
+
o.on("--refuse-unknown") { opts[:unknown_site] = :refuse }
|
|
281
|
+
o.on("--ask N", Integer) { |v| opts[:ask] = v }
|
|
282
|
+
o.on("--sqrlonly") { opts[:options][:sqrlonly] = true }
|
|
283
|
+
o.on("--hardlock") { opts[:options][:hardlock] = true }
|
|
284
|
+
o.on("--noiptest") { opts[:options][:noiptest] = true }
|
|
285
|
+
o.on("--cookies-out FILE") { |v| opts[:cookies_out] = v }
|
|
286
|
+
o.on("--cookies-format FORMAT", %w[netscape json]) { |v| opts[:cookies_format] = v }
|
|
287
|
+
o.on("--insecure") { opts[:insecure] = true }
|
|
288
|
+
o.on("--no-verify-tls") { opts[:verify_tls] = false }
|
|
289
|
+
o.on("--ca-file FILE") { |v| opts[:ca_file] = v }
|
|
290
|
+
o.on("--json") { opts[:json] = true }
|
|
291
|
+
o.on("-v", "--verbose") { opts[:verbose] = true }
|
|
292
|
+
end
|
|
293
|
+
parser.parse!(argv)
|
|
294
|
+
target = argv.shift
|
|
295
|
+
raise ArgumentError, "#{command} needs a URL" unless target
|
|
296
|
+
|
|
297
|
+
identity = load_identity(**opts.slice(:identity, :password, :iuk_hex))
|
|
298
|
+
logger = opts[:verbose] ? stderr_logger : nil
|
|
299
|
+
transport = Transport.new(allow_http: opts[:insecure] ? :loopback : false, verify_tls: opts.fetch(:verify_tls, true),
|
|
300
|
+
ca_file: opts[:ca_file], logger: logger)
|
|
301
|
+
agent = Agent.new(identity: identity, transport: transport, cps: opts[:cps], unknown_site: opts[:unknown_site],
|
|
302
|
+
options: opts[:options], alt_id: opts[:alt_id], logger: logger, insecure: opts[:insecure],
|
|
303
|
+
ask_handler: opts.key?(:ask) ? ->(_ask) { opts[:ask] } : nil)
|
|
304
|
+
result =
|
|
305
|
+
case command
|
|
306
|
+
when :login then agent.login(target)
|
|
307
|
+
when :query then agent.query(target)
|
|
308
|
+
when :disable then agent.disable(target)
|
|
309
|
+
when :enable then agent.enable(target, rescue_code: opts[:rescue_code] || ENV.fetch("SQRL_RESCUE_CODE", nil))
|
|
310
|
+
when :remove then agent.remove(target, rescue_code: opts[:rescue_code] || ENV.fetch("SQRL_RESCUE_CODE", nil))
|
|
311
|
+
end
|
|
312
|
+
write_cookies(transport, opts) if opts[:cookies_out]
|
|
313
|
+
if opts[:json]
|
|
314
|
+
@out.puts JSON.generate(result.to_h)
|
|
315
|
+
else
|
|
316
|
+
@out.puts describe(result)
|
|
317
|
+
end
|
|
318
|
+
EXIT_OK
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def describe(result)
|
|
322
|
+
lines = ["#{result.command}: #{result.state.kind}#{" (disabled)" if result.state.disabled?} tif=0x#{result.tif&.to_hex}"]
|
|
323
|
+
lines << "idk: #{result.idk}"
|
|
324
|
+
if result.command != :query
|
|
325
|
+
notes = [(" (account created)" if result.created?), (" (account re-keyed)" if result.rekeyed?)].compact.join
|
|
326
|
+
lines << "authenticated: #{result.authenticated?}#{notes}"
|
|
327
|
+
end
|
|
328
|
+
lines << "cps url: #{result.cps_url}" if result.cps_url
|
|
329
|
+
lines << "landing: HTTP #{result.landing.status} #{result.landing.final_url}" if result.landing
|
|
330
|
+
lines.join("\n")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def write_cookies(transport, opts)
|
|
334
|
+
content = opts[:cookies_format] == "json" ? JSON.pretty_generate(transport.cookies_as_hashes) : transport.cookies_netscape
|
|
335
|
+
File.write(opts[:cookies_out], content)
|
|
336
|
+
File.chmod(0o600, opts[:cookies_out])
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# ---- conformance ---------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
def conformance_command(argv)
|
|
342
|
+
opts = { features: {}, ask_button: 1 }
|
|
343
|
+
parser = OptionParser.new do |o|
|
|
344
|
+
o.on("--report FILE") { |v| opts[:report] = v }
|
|
345
|
+
o.on("--feature NAME=VALUE") do |v|
|
|
346
|
+
name, value = v.split("=", 2)
|
|
347
|
+
opts[:features][name.to_sym] = %w[on true yes 1].include?(value.to_s.downcase)
|
|
348
|
+
end
|
|
349
|
+
o.on("--only CASES") { |v| opts[:only] = v.split(",").map(&:strip) }
|
|
350
|
+
o.on("--ask-button N", Integer) { |v| opts[:ask_button] = v }
|
|
351
|
+
o.on("--insecure") { opts[:insecure] = true }
|
|
352
|
+
o.on("--no-verify-tls") { opts[:verify_tls] = false }
|
|
353
|
+
o.on("--ca-file FILE") { |v| opts[:ca_file] = v }
|
|
354
|
+
o.on("--json") { opts[:json] = true }
|
|
355
|
+
o.on("-v", "--verbose") { opts[:verbose] = true }
|
|
356
|
+
end
|
|
357
|
+
parser.parse!(argv)
|
|
358
|
+
login_url = argv.shift
|
|
359
|
+
raise ArgumentError, "conformance needs the login page URL" unless login_url
|
|
360
|
+
|
|
361
|
+
require "sqrl/client/conformance"
|
|
362
|
+
logger = opts[:verbose] ? stderr_logger : nil
|
|
363
|
+
transport_factory = lambda do
|
|
364
|
+
Transport.new(allow_http: opts[:insecure] ? :loopback : false, verify_tls: opts.fetch(:verify_tls, true),
|
|
365
|
+
ca_file: opts[:ca_file], logger: logger)
|
|
366
|
+
end
|
|
367
|
+
runner = Conformance::Runner.new(login_url: login_url, features: opts[:features], only: opts[:only],
|
|
368
|
+
ask_button: opts[:ask_button], transport_factory: transport_factory,
|
|
369
|
+
logger: logger, insecure: opts[:insecure] ? true : false)
|
|
370
|
+
report = runner.run
|
|
371
|
+
File.write(opts[:report], report.to_junit_xml) if opts[:report]
|
|
372
|
+
@out.puts(opts[:json] ? JSON.generate(report.to_h) : report.to_s)
|
|
373
|
+
report.passed? ? EXIT_OK : EXIT_PROTOCOL
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# ---- helpers ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
def load_identity(identity: nil, password: nil, iuk_hex: nil)
|
|
379
|
+
iuk_hex ||= ENV.fetch("SQRL_IUK_HEX", nil)
|
|
380
|
+
return Identity.ephemeral(Util.hex_decode(iuk_hex)) if iuk_hex && !iuk_hex.empty?
|
|
381
|
+
|
|
382
|
+
file = identity || ENV.fetch("SQRL_IDENTITY_FILE", nil)
|
|
383
|
+
if file.nil?
|
|
384
|
+
raise ArgumentError,
|
|
385
|
+
"an identity is required (--identity FILE, --iuk-hex HEX, SQRL_IDENTITY_FILE or SQRL_IUK_HEX)"
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
password ||= ENV.fetch("SQRL_PASSWORD", nil)
|
|
389
|
+
password ||= File.read(ENV["SQRL_PASSWORD_FILE"]).chomp if ENV["SQRL_PASSWORD_FILE"]
|
|
390
|
+
password ||= prompt_password("Identity password: ")
|
|
391
|
+
Identity.load(file, password: password)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def prompt_password(label)
|
|
395
|
+
raise ArgumentError, "a password is required (--password, --password-file or SQRL_PASSWORD)" unless $stdin.tty?
|
|
396
|
+
|
|
397
|
+
@err.print label
|
|
398
|
+
pw = $stdin.noecho(&:gets).to_s.chomp
|
|
399
|
+
@err.puts
|
|
400
|
+
pw
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def stderr_logger
|
|
404
|
+
require "logger"
|
|
405
|
+
Logger.new(@err, level: Logger::DEBUG, formatter: ->(_sev, _time, _prog, msg) { "#{msg}\n" })
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Client
|
|
5
|
+
module Conformance
|
|
6
|
+
# The outcome of one conformance case.
|
|
7
|
+
Case = Struct.new(:name, :group, :status, :message, :duration, :transcript, :error, keyword_init: true) do
|
|
8
|
+
def passed? = status == :passed
|
|
9
|
+
def failed? = status == :failed
|
|
10
|
+
def skipped? = status == :skipped
|
|
11
|
+
|
|
12
|
+
# @return [Hash] the values as a plain Hash
|
|
13
|
+
def to_h
|
|
14
|
+
{ name: name, group: group, status: status, message: message, duration: duration&.round(3) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [String] one line per case
|
|
18
|
+
def to_s
|
|
19
|
+
mark = { passed: "PASS", failed: "FAIL", skipped: "SKIP" }[status]
|
|
20
|
+
line = "#{mark} #{group}/#{name}"
|
|
21
|
+
line += " (#{duration.round(2)}s)" if duration
|
|
22
|
+
line += "\n #{message}" if message && !passed?
|
|
23
|
+
line
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Every case of a run.
|
|
28
|
+
class Report
|
|
29
|
+
attr_reader :cases, :login_url, :started_at, :finished_at
|
|
30
|
+
|
|
31
|
+
def initialize(login_url:)
|
|
32
|
+
@login_url = login_url
|
|
33
|
+
@cases = []
|
|
34
|
+
@started_at = Time.now
|
|
35
|
+
@finished_at = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Record a finished case.
|
|
39
|
+
# @api private
|
|
40
|
+
def <<(kase)
|
|
41
|
+
@cases << kase
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Mark the run complete and record its duration.
|
|
46
|
+
# @api private
|
|
47
|
+
def finish!
|
|
48
|
+
@finished_at = Time.now
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Array<Case>] cases that passed
|
|
53
|
+
def passed = @cases.select(&:passed?)
|
|
54
|
+
# @return [Array<Case>] cases that failed
|
|
55
|
+
def failed = @cases.select(&:failed?)
|
|
56
|
+
# @return [Array<Case>] cases that were skipped
|
|
57
|
+
def skipped = @cases.select(&:skipped?)
|
|
58
|
+
|
|
59
|
+
# True when nothing failed (skips do not count against the server).
|
|
60
|
+
def passed? = failed.empty?
|
|
61
|
+
|
|
62
|
+
# @return [Float, nil] seconds the run took
|
|
63
|
+
def duration = ((@finished_at || Time.now) - @started_at)
|
|
64
|
+
|
|
65
|
+
# @return [String] one-line totals
|
|
66
|
+
def summary
|
|
67
|
+
"#{passed.size} passed, #{failed.size} failed, #{skipped.size} skipped in #{duration.round(1)}s"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [String] one line per case
|
|
71
|
+
def to_s
|
|
72
|
+
lines = ["SQRL conformance: #{@login_url}"]
|
|
73
|
+
lines.concat(@cases.map(&:to_s))
|
|
74
|
+
lines << summary
|
|
75
|
+
lines.join("\n")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Hash] the values as a plain Hash
|
|
79
|
+
def to_h
|
|
80
|
+
{ login_url: @login_url, summary: summary, passed: passed?, cases: @cases.map(&:to_h) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# JUnit XML, for CI dashboards.
|
|
84
|
+
def to_junit_xml
|
|
85
|
+
esc = ->(s) { s.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """) }
|
|
86
|
+
out = +%(<?xml version="1.0" encoding="UTF-8"?>\n)
|
|
87
|
+
out << %(<testsuite name="sqrl-conformance" tests="#{@cases.size}" failures="#{failed.size}" ) \
|
|
88
|
+
"skipped=\"#{skipped.size}\" time=\"#{duration.round(3)}\">\n"
|
|
89
|
+
@cases.each do |c|
|
|
90
|
+
out << %( <testcase classname="#{esc.call(c.group)}" name="#{esc.call(c.name)}" time="#{(c.duration || 0).round(3)}">)
|
|
91
|
+
out << %(<failure message="#{esc.call(c.message)}"/>) if c.failed?
|
|
92
|
+
out << %(<skipped message="#{esc.call(c.message)}"/>) if c.skipped?
|
|
93
|
+
out << "</testcase>\n"
|
|
94
|
+
end
|
|
95
|
+
out << "</testsuite>\n"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqrl/client"
|
|
4
|
+
require "sqrl/client/conformance"
|
|
5
|
+
|
|
6
|
+
# RSpec shared examples running the conformance suite case by case.
|
|
7
|
+
#
|
|
8
|
+
# require "sqrl/client/conformance/rspec"
|
|
9
|
+
#
|
|
10
|
+
# RSpec.describe "my SQRL site" do
|
|
11
|
+
# let(:login_url) { "http://127.0.0.1:9292/login" }
|
|
12
|
+
# let(:conformance_features) { { ask: false } } # optional
|
|
13
|
+
# let(:conformance_options) { { success_matcher: ... } } # optional Runner keywords
|
|
14
|
+
# it_behaves_like "a SQRL server"
|
|
15
|
+
# end
|
|
16
|
+
RSpec.shared_examples "a SQRL server" do
|
|
17
|
+
let(:conformance_features) { {} } unless method_defined?(:conformance_features)
|
|
18
|
+
let(:conformance_options) { {} } unless method_defined?(:conformance_options)
|
|
19
|
+
let(:conformance_runner) do
|
|
20
|
+
SQRL::Client::Conformance::Runner.new(login_url: login_url, features: conformance_features, **conformance_options)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
SQRL::Client::Conformance::Runner::CASES.each do |name, (group, _feature)|
|
|
24
|
+
it "#{group}: #{name.to_s.tr("_", " ")}" do
|
|
25
|
+
kase = conformance_runner.run_case(name)
|
|
26
|
+
skip(kase.message) if kase.skipped?
|
|
27
|
+
raise kase.error || RSpec::Expectations::ExpectationNotMetError.new(kase.message) if kase.failed?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|