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,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Client
|
|
5
|
+
# Base class of every error raised by the client gem.
|
|
6
|
+
class Error < SQRL::Error; end
|
|
7
|
+
|
|
8
|
+
# No SQRL login URL could be found for the target.
|
|
9
|
+
class DiscoveryError < Error; end
|
|
10
|
+
|
|
11
|
+
# The server posed an +ask=+ question and no +ask_handler+ was configured to answer it.
|
|
12
|
+
class AskRequired < Error
|
|
13
|
+
# @return [SQRL::Ask]
|
|
14
|
+
attr_reader :ask
|
|
15
|
+
|
|
16
|
+
def initialize(ask)
|
|
17
|
+
@ask = ask
|
|
18
|
+
super("the server asks: #{ask.message.inspect} (buttons: #{ask.buttons.map(&:label).inspect}); " \
|
|
19
|
+
"configure an ask_handler to answer it")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The transaction ended without the requested outcome. +tif+ is the last TIF received,
|
|
24
|
+
# +reason+ a Symbol such as +:disabled+, +:superseded+, +:unknown_site+, +:ip_mismatch+,
|
|
25
|
+
# +:command_failed+ or +:transient+.
|
|
26
|
+
class LoginFailed < Error
|
|
27
|
+
attr_reader :tif, :reason, :state, :result
|
|
28
|
+
|
|
29
|
+
def initialize(message, reason:, tif: nil, state: nil, result: nil)
|
|
30
|
+
@reason = reason
|
|
31
|
+
@tif = tif
|
|
32
|
+
@state = state
|
|
33
|
+
@result = result
|
|
34
|
+
super(message)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The HTTP layer failed (connection refused, TLS error, timeout, non-2xx SQRL endpoint).
|
|
39
|
+
class TransportError < Error
|
|
40
|
+
attr_reader :url, :status
|
|
41
|
+
|
|
42
|
+
def initialize(message, url: nil, status: nil)
|
|
43
|
+
@url = url
|
|
44
|
+
@status = status
|
|
45
|
+
super(message)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# An operation needs the Identity Unlock Key (rescue code) and it is not available.
|
|
50
|
+
class IdentityLocked < Error; end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Client
|
|
5
|
+
# An identity as a bot uses it: an unlocked {SQRL::UnlockedIdentity} plus, when known,
|
|
6
|
+
# the Identity Unlock Key needed for +enable+, +remove+ and re-keying, optionally backed
|
|
7
|
+
# by an S4 identity file.
|
|
8
|
+
#
|
|
9
|
+
# SQRL::Client::Identity.load("bot.sqrl", password: "…") # S4 file (binary or SQRLDATA text)
|
|
10
|
+
# SQRL::Client::Identity.from_textual(text, rescue_code: "…") # rescue-only textual import
|
|
11
|
+
# SQRL::Client::Identity.create(password: "…") # brand new, returns the rescue code
|
|
12
|
+
# SQRL::Client::Identity.ephemeral # random IUK, no EnScrypt (tests)
|
|
13
|
+
# SQRL::Client::Identity.ephemeral("seed") # deterministic from a seed
|
|
14
|
+
# SQRL::Client::Identity.from_env # SQRL_IDENTITY_FILE / SQRL_IUK_HEX …
|
|
15
|
+
class Identity
|
|
16
|
+
# @return [SQRL::UnlockedIdentity]
|
|
17
|
+
attr_reader :unlocked
|
|
18
|
+
# @return [SQRL::S4Identity, nil] the identity file, when there is one
|
|
19
|
+
attr_reader :s4
|
|
20
|
+
# @return [String, nil] where the identity file lives
|
|
21
|
+
attr_reader :path
|
|
22
|
+
# @return [String, nil] the rescue code, only right after {.create} or {#rekey!}
|
|
23
|
+
attr_reader :rescue_code
|
|
24
|
+
|
|
25
|
+
# A fast clock for identity creation in tests: every call advances one second so a
|
|
26
|
+
# one-second EnScrypt finishes after one iteration.
|
|
27
|
+
def self.fast_clock
|
|
28
|
+
t = 0
|
|
29
|
+
-> { t += 1000 }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Load an S4 identity file (binary +sqrldata+ or +SQRLDATA+ text) and unlock it.
|
|
33
|
+
#
|
|
34
|
+
# @param path [String]
|
|
35
|
+
# @param password [String]
|
|
36
|
+
# @param rescue_code [String, nil] also recover the IUK (slow; needed for enable/remove)
|
|
37
|
+
def self.load(path, password:, rescue_code: nil)
|
|
38
|
+
s4 = S4Identity.parse(File.binread(path))
|
|
39
|
+
from_s4(s4, password: password, rescue_code: rescue_code, path: path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Unlock a parsed {SQRL::S4Identity}.
|
|
43
|
+
def self.from_s4(s4, password:, rescue_code: nil, path: nil)
|
|
44
|
+
unless s4.password_block?
|
|
45
|
+
raise IdentityLocked,
|
|
46
|
+
"identity file has no password block; import it with the rescue code"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
identity = new(s4.unlock(password.to_s), s4: s4, path: path)
|
|
50
|
+
identity.unlock_iuk!(rescue_code) if rescue_code
|
|
51
|
+
identity
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Import a rescue-only textual identity (the base56 export) with its rescue code. The
|
|
55
|
+
# IUK is recovered, so the identity can enable/remove right away. Pass +password:+ to
|
|
56
|
+
# also build a password block (returned via {#s4}).
|
|
57
|
+
def self.from_textual(text, rescue_code:, password: nil, settings: nil, now_ms: nil)
|
|
58
|
+
s4 = S4Identity.parse_blocks(Textual.decode(text))
|
|
59
|
+
iuk = s4.unlock_iuk(rescue_code)
|
|
60
|
+
imk = SQRL::Identity.imk_from_iuk(iuk)
|
|
61
|
+
previous = s4.type3 ? s4.type3.decrypt(imk) : []
|
|
62
|
+
unlocked = UnlockedIdentity.from_iuk(iuk, previous)
|
|
63
|
+
if password
|
|
64
|
+
kw = now_ms ? { now_ms: now_ms } : {}
|
|
65
|
+
s4.set_password(unlocked, password, settings || Storage::IdentitySettings.new, **kw)
|
|
66
|
+
end
|
|
67
|
+
new(unlocked, s4: s4, iuk: iuk)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Create a brand-new identity. The rescue code is generated unless given and is
|
|
71
|
+
# available as {#rescue_code}; the S4 file is {#s4} and is written to +path+ when
|
|
72
|
+
# given.
|
|
73
|
+
#
|
|
74
|
+
# @param password [String]
|
|
75
|
+
# @param rescue_code [String, nil] 24 digits (with or without dashes)
|
|
76
|
+
# @param settings [SQRL::Storage::IdentitySettings, nil]
|
|
77
|
+
# @param pw_seconds [Integer, nil] EnScrypt time for the password block (default 5)
|
|
78
|
+
# @param rescue_seconds [Numeric, nil] EnScrypt time for the rescue block (default 5)
|
|
79
|
+
# @param path [String, nil]
|
|
80
|
+
# @param now_ms [#call, nil] clock override (see {.fast_clock}) for fast test identities
|
|
81
|
+
def self.create(password:, rescue_code: nil, settings: nil, pw_seconds: nil, rescue_seconds: nil,
|
|
82
|
+
path: nil, now_ms: nil, &progress)
|
|
83
|
+
settings ||= Storage::IdentitySettings.new
|
|
84
|
+
settings = settings.dup.tap { |s| s.pw_verify_seconds = pw_seconds } if pw_seconds
|
|
85
|
+
rescue_code ||= Storage.generate_rescue_code
|
|
86
|
+
iuk = Util.random_bytes(32)
|
|
87
|
+
kw = now_ms ? { now_ms: now_ms } : {}
|
|
88
|
+
rescue_ms = ((rescue_seconds || 5) * 1000).to_i
|
|
89
|
+
s4 = S4Identity.create(iuk, password, rescue_code, settings, rescue_ms, **kw, &progress)
|
|
90
|
+
File.binwrite(path, s4.to_bytes) if path
|
|
91
|
+
new(UnlockedIdentity.from_iuk(iuk), s4: s4, iuk: iuk, path: path,
|
|
92
|
+
rescue_code: Storage.format_rescue_code(rescue_code))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# An identity straight from a 32-byte IUK, skipping EnScrypt and storage entirely.
|
|
96
|
+
# With no seed the IUK is random; with a seed it is derived deterministically
|
|
97
|
+
# (32 raw bytes are used as-is, anything else is hashed), which gives reproducible
|
|
98
|
+
# identities for test suites.
|
|
99
|
+
def self.ephemeral(seed = nil, previous: [])
|
|
100
|
+
iuk = case seed
|
|
101
|
+
when nil then Util.random_bytes(32)
|
|
102
|
+
when String then seed.b.bytesize == 32 ? seed.b : Crypto.sha256("sqrl-client-ephemeral:#{seed.to_s.b}")
|
|
103
|
+
else Crypto.sha256("sqrl-client-ephemeral:#{seed.to_s.b}")
|
|
104
|
+
end
|
|
105
|
+
new(UnlockedIdentity.from_iuk(iuk, previous), iuk: iuk, previous_iuks: previous)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Build an identity from environment variables:
|
|
109
|
+
#
|
|
110
|
+
# * +SQRL_IUK_HEX+ – 64 hex characters: an ephemeral identity (tests, CI)
|
|
111
|
+
# * +SQRL_IDENTITY_FILE+ with +SQRL_PASSWORD+ or +SQRL_PASSWORD_FILE+ – an S4 file
|
|
112
|
+
# * +SQRL_RESCUE_CODE+ – optional, recovers the IUK for enable/remove
|
|
113
|
+
#
|
|
114
|
+
# @return [Identity, nil] nil when nothing is configured
|
|
115
|
+
def self.from_env(env = ENV)
|
|
116
|
+
if (hex = env["SQRL_IUK_HEX"]) && !hex.empty?
|
|
117
|
+
return ephemeral(Util.hex_decode(hex))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
file = env["SQRL_IDENTITY_FILE"]
|
|
121
|
+
return nil if file.nil? || file.empty?
|
|
122
|
+
|
|
123
|
+
password = env["SQRL_PASSWORD"]
|
|
124
|
+
password = File.read(env["SQRL_PASSWORD_FILE"]).chomp if password.nil? && env["SQRL_PASSWORD_FILE"]
|
|
125
|
+
raise IdentityLocked, "SQRL_PASSWORD or SQRL_PASSWORD_FILE is required with SQRL_IDENTITY_FILE" if password.nil?
|
|
126
|
+
|
|
127
|
+
load(file, password: password, rescue_code: env["SQRL_RESCUE_CODE"])
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @api private
|
|
131
|
+
def initialize(unlocked, s4: nil, iuk: nil, path: nil, rescue_code: nil, previous_iuks: nil)
|
|
132
|
+
@unlocked = unlocked
|
|
133
|
+
@s4 = s4
|
|
134
|
+
@iuk = iuk&.b
|
|
135
|
+
@path = path
|
|
136
|
+
@rescue_code = rescue_code
|
|
137
|
+
@previous_iuks = previous_iuks || unlocked.previous_iuks
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The Identity Unlock Key when it is known (ephemeral identities, freshly created
|
|
141
|
+
# ones, or after {#unlock_iuk!}); nil otherwise.
|
|
142
|
+
attr_reader :iuk
|
|
143
|
+
|
|
144
|
+
# Whether +enable+/+remove+/+rekey!+ can run without a rescue code.
|
|
145
|
+
def iuk? = !@iuk.nil?
|
|
146
|
+
|
|
147
|
+
# Recover the IUK from the identity file's rescue block (slow: EnScrypt).
|
|
148
|
+
#
|
|
149
|
+
# @return [String] the IUK
|
|
150
|
+
def unlock_iuk!(rescue_code)
|
|
151
|
+
return @iuk if @iuk
|
|
152
|
+
|
|
153
|
+
raise IdentityLocked, "this identity has no rescue block; the IUK cannot be recovered" unless @s4&.rescue_block?
|
|
154
|
+
|
|
155
|
+
iuk = @s4.unlock_iuk(rescue_code)
|
|
156
|
+
raise CryptoError, "rescue code does not belong to this identity" unless @unlocked.matches_iuk?(iuk)
|
|
157
|
+
|
|
158
|
+
@iuk = iuk
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Re-key: a fresh IUK replaces the current one and the current one becomes the most
|
|
162
|
+
# recent previous identity, exactly as a SQRL client's "rekey identity" does. Needs the
|
|
163
|
+
# current IUK (ephemeral/created identities have it; file identities need
|
|
164
|
+
# +rescue_code:+). File-backed identities are re-encrypted under +password:+ (and a new
|
|
165
|
+
# rescue code, available as {#rescue_code}); the file at {#path} is rewritten.
|
|
166
|
+
#
|
|
167
|
+
# @return [self]
|
|
168
|
+
def rekey!(password: nil, rescue_code: nil, new_rescue_code: nil, settings: nil, now_ms: nil, rescue_seconds: nil)
|
|
169
|
+
old_iuk = @iuk || (rescue_code && unlock_iuk!(rescue_code))
|
|
170
|
+
raise IdentityLocked, "re-keying needs the current IUK (rescue code)" unless old_iuk
|
|
171
|
+
|
|
172
|
+
new_iuk = Util.random_bytes(32)
|
|
173
|
+
if @s4
|
|
174
|
+
raise ArgumentError, "password: is required to re-key a file-backed identity" unless password
|
|
175
|
+
|
|
176
|
+
new_rescue_code ||= Storage.generate_rescue_code
|
|
177
|
+
kw = now_ms ? { now_ms: now_ms } : {}
|
|
178
|
+
rescue_ms = ((rescue_seconds || 5) * 1000).to_i
|
|
179
|
+
@unlocked = @s4.rekey(@unlocked, old_iuk, new_iuk, password, new_rescue_code,
|
|
180
|
+
settings || @s4.settings, rescue_ms, **kw)
|
|
181
|
+
@rescue_code = Storage.format_rescue_code(new_rescue_code)
|
|
182
|
+
File.binwrite(@path, @s4.to_bytes) if @path
|
|
183
|
+
else
|
|
184
|
+
previous = ([old_iuk] + @unlocked.previous_iuks).first(Storage::MAX_PREVIOUS_KEYS)
|
|
185
|
+
@unlocked = UnlockedIdentity.from_iuk(new_iuk, previous)
|
|
186
|
+
end
|
|
187
|
+
@iuk = new_iuk
|
|
188
|
+
self
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# A detached copy of this identity as it was before the most recent {#rekey!}:
|
|
192
|
+
# handy for testing that a superseded identity is refused.
|
|
193
|
+
def previous_identity(index = 0)
|
|
194
|
+
iuk = @unlocked.previous_iuks[index]
|
|
195
|
+
return nil unless iuk
|
|
196
|
+
|
|
197
|
+
self.class.ephemeral(iuk)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# @return [Integer] number of previous identities carried
|
|
201
|
+
def previous_count = @unlocked.previous_count
|
|
202
|
+
|
|
203
|
+
# 16 hex characters identifying the identity without revealing key material.
|
|
204
|
+
def fingerprint = @unlocked.fingerprint
|
|
205
|
+
|
|
206
|
+
# Save the identity file (requires an S4-backed identity).
|
|
207
|
+
def save(path = @path)
|
|
208
|
+
raise IdentityLocked, "ephemeral identities have no file to save" unless @s4
|
|
209
|
+
raise ArgumentError, "no path given" unless path
|
|
210
|
+
|
|
211
|
+
File.binwrite(path, @s4.to_bytes)
|
|
212
|
+
@path = path
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The identity file as +SQRLDATA+ text, or the IUK in hex for ephemeral identities
|
|
216
|
+
# (both forms load back with {.load}/{.from_env}).
|
|
217
|
+
def export
|
|
218
|
+
@s4 ? @s4.to_text : Util.hex_encode(@iuk)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# @return [String] a short description with secret material redacted
|
|
222
|
+
def inspect
|
|
223
|
+
"#<SQRL::Client::Identity fingerprint=#{fingerprint} iuk=#{iuk? ? "known" : "locked"} " \
|
|
224
|
+
"previous=#{previous_count}#{" path=#{@path}" if @path}>"
|
|
225
|
+
end
|
|
226
|
+
alias to_s inspect
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "http-cookie"
|
|
6
|
+
|
|
7
|
+
module SQRL
|
|
8
|
+
module Client
|
|
9
|
+
# The HTTP layer: Net::HTTP with a persistent cookie jar, redirects, timeouts and the
|
|
10
|
+
# plain-http policy SQRL needs (+https+ everywhere, +http+ only for loopback hosts
|
|
11
|
+
# while developing).
|
|
12
|
+
#
|
|
13
|
+
# Anything responding to +get(url, headers)+ and +post_form(url, body, headers)+ and
|
|
14
|
+
# returning a {Response} can replace it (for example a Faraday adapter).
|
|
15
|
+
class Transport
|
|
16
|
+
# An HTTP response after redirects were followed.
|
|
17
|
+
Response = Struct.new(:status, :headers, :body, :final_url, :redirects, keyword_init: true) do
|
|
18
|
+
def success? = (200..299).cover?(status)
|
|
19
|
+
def redirect? = (300..399).cover?(status)
|
|
20
|
+
|
|
21
|
+
# @return [String, nil] a response header by name (case-insensitive)
|
|
22
|
+
def [](name) = headers[name.to_s.downcase]
|
|
23
|
+
|
|
24
|
+
# @return [Object] the body parsed as JSON
|
|
25
|
+
# @raise [JSON::ParserError]
|
|
26
|
+
def json
|
|
27
|
+
require "json"
|
|
28
|
+
JSON.parse(body)
|
|
29
|
+
rescue JSON::ParserError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [String] a short description with secret material redacted
|
|
34
|
+
def inspect
|
|
35
|
+
"#<SQRL::Client::Transport::Response #{status} #{final_url}>"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# HTTP statuses followed as redirects.
|
|
40
|
+
REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
|
|
41
|
+
# User-Agent sent with every request.
|
|
42
|
+
DEFAULT_USER_AGENT = "sqrl-client/#{SQRL::VERSION} (Ruby #{RUBY_VERSION})".freeze
|
|
43
|
+
|
|
44
|
+
# @return [HTTP::CookieJar]
|
|
45
|
+
attr_reader :cookies
|
|
46
|
+
attr_accessor :open_timeout, :read_timeout, :max_redirects, :user_agent, :logger
|
|
47
|
+
|
|
48
|
+
# @param cookies [HTTP::CookieJar] shared cookie jar
|
|
49
|
+
# @param allow_http [Symbol, Boolean] +false+ (default) never speaks plain http;
|
|
50
|
+
# +:loopback+ permits it for loopback hosts only (testing); +true+ everywhere
|
|
51
|
+
# @param verify_tls [Boolean]
|
|
52
|
+
# @param ca_file [String, nil]
|
|
53
|
+
# @param proxy [String, nil, :env] proxy URL; +:env+ (default) honours +http_proxy+ /
|
|
54
|
+
# +https_proxy+ / +no_proxy+; loopback hosts never go through a proxy
|
|
55
|
+
def initialize(cookies: HTTP::CookieJar.new, allow_http: false, verify_tls: true, ca_file: nil,
|
|
56
|
+
proxy: :env, open_timeout: 10, read_timeout: 30, max_redirects: 10,
|
|
57
|
+
user_agent: DEFAULT_USER_AGENT, logger: nil)
|
|
58
|
+
@cookies = cookies
|
|
59
|
+
@allow_http = allow_http
|
|
60
|
+
@verify_tls = verify_tls
|
|
61
|
+
@ca_file = ca_file
|
|
62
|
+
@proxy = proxy
|
|
63
|
+
@open_timeout = open_timeout
|
|
64
|
+
@read_timeout = read_timeout
|
|
65
|
+
@max_redirects = max_redirects
|
|
66
|
+
@user_agent = user_agent
|
|
67
|
+
@logger = logger
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# GET +url+, following redirects with the cookie jar.
|
|
71
|
+
def get(url, headers = {}, follow_redirects: true)
|
|
72
|
+
request(:get, url, nil, headers, follow_redirects: follow_redirects)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# POST an +application/x-www-form-urlencoded+ body (already encoded).
|
|
76
|
+
def post_form(url, body, headers = {}, follow_redirects: true)
|
|
77
|
+
headers = { "Content-Type" => "application/x-www-form-urlencoded" }.merge(headers)
|
|
78
|
+
request(:post, url, body, headers, follow_redirects: follow_redirects)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Remove every cookie (a fresh "browser").
|
|
82
|
+
def clear_cookies!
|
|
83
|
+
@cookies.clear
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Cookies in Netscape +cookies.txt+ format (what curl reads with +-b+).
|
|
88
|
+
def cookies_netscape
|
|
89
|
+
lines = ["# Netscape HTTP Cookie File"]
|
|
90
|
+
@cookies.each do |c|
|
|
91
|
+
domain = c.for_domain? ? ".#{c.domain}" : c.domain
|
|
92
|
+
expires = c.expires ? c.expires.to_i : 0
|
|
93
|
+
lines << [domain, c.for_domain? ? "TRUE" : "FALSE", c.path, c.secure? ? "TRUE" : "FALSE",
|
|
94
|
+
expires, c.name, c.value].join("\t")
|
|
95
|
+
end
|
|
96
|
+
"#{lines.join("\n")}\n"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Cookies as an Array of Hashes (serialisable to JSON).
|
|
100
|
+
def cookies_as_hashes
|
|
101
|
+
@cookies.map do |c|
|
|
102
|
+
{ name: c.name, value: c.value, domain: c.domain, path: c.path, secure: c.secure?,
|
|
103
|
+
httponly: c.httponly?, expires: c.expires&.iso8601 }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def request(method, url, body, headers, follow_redirects:, redirects: 0)
|
|
110
|
+
uri = URI.parse(url.to_s)
|
|
111
|
+
check_scheme!(uri)
|
|
112
|
+
http = build_http(uri)
|
|
113
|
+
req = build_request(method, uri, body, headers)
|
|
114
|
+
@logger&.debug { "[sqrl-client] #{method.to_s.upcase} #{uri}" }
|
|
115
|
+
res = http.start { |h| h.request(req) }
|
|
116
|
+
store_cookies(res, uri)
|
|
117
|
+
status = res.code.to_i
|
|
118
|
+
if follow_redirects && REDIRECT_STATUSES.include?(status) && res["location"]
|
|
119
|
+
raise TransportError.new("too many redirects", url: url) if redirects >= @max_redirects
|
|
120
|
+
|
|
121
|
+
target = uri.merge(res["location"]).to_s
|
|
122
|
+
next_method = [307, 308].include?(status) ? method : :get
|
|
123
|
+
next_body = next_method == :get ? nil : body
|
|
124
|
+
next_headers = next_method == :get ? headers.reject { |k, _| k.to_s.casecmp?("content-type") } : headers
|
|
125
|
+
return request(next_method, target, next_body, next_headers, follow_redirects: true,
|
|
126
|
+
redirects: redirects + 1)
|
|
127
|
+
end
|
|
128
|
+
Response.new(status: status, headers: res.each_header.to_h, body: res.body.to_s,
|
|
129
|
+
final_url: uri.to_s, redirects: redirects)
|
|
130
|
+
rescue SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, SocketError, EOFError, Net::ProtocolError => e
|
|
131
|
+
raise TransportError.new("#{method.to_s.upcase} #{url}: #{e.class}: #{e.message}", url: url)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def check_scheme!(uri)
|
|
135
|
+
case uri.scheme
|
|
136
|
+
when "https" then nil
|
|
137
|
+
when "http"
|
|
138
|
+
allowed = @allow_http == true || (@allow_http == :loopback && URL.loopback?(uri.host.to_s.downcase))
|
|
139
|
+
unless allowed
|
|
140
|
+
raise TransportError.new("plain http is not allowed for #{uri.host} (allow_http: #{@allow_http.inspect})",
|
|
141
|
+
url: uri.to_s)
|
|
142
|
+
end
|
|
143
|
+
else
|
|
144
|
+
raise TransportError.new("unsupported URL scheme #{uri.scheme.inspect}", url: uri.to_s)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_http(uri)
|
|
149
|
+
http =
|
|
150
|
+
if URL.loopback?(uri.host.to_s.downcase) || @proxy.nil?
|
|
151
|
+
Net::HTTP.new(uri.host, uri.port, nil)
|
|
152
|
+
elsif @proxy == :env
|
|
153
|
+
Net::HTTP.new(uri.host, uri.port)
|
|
154
|
+
else
|
|
155
|
+
p = URI.parse(@proxy.to_s)
|
|
156
|
+
Net::HTTP.new(uri.host, uri.port, p.host, p.port, p.user, p.password)
|
|
157
|
+
end
|
|
158
|
+
http.open_timeout = @open_timeout
|
|
159
|
+
http.read_timeout = @read_timeout
|
|
160
|
+
if uri.scheme == "https"
|
|
161
|
+
http.use_ssl = true
|
|
162
|
+
http.verify_mode = @verify_tls ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
163
|
+
http.ca_file = @ca_file if @ca_file
|
|
164
|
+
end
|
|
165
|
+
http
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def build_request(method, uri, body, headers)
|
|
169
|
+
klass = method == :post ? Net::HTTP::Post : Net::HTTP::Get
|
|
170
|
+
req = klass.new(uri.request_uri)
|
|
171
|
+
req["User-Agent"] = @user_agent
|
|
172
|
+
req["Accept"] ||= "*/*"
|
|
173
|
+
headers.each { |k, v| req[k.to_s] = v }
|
|
174
|
+
cookie = HTTP::Cookie.cookie_value(@cookies.cookies(uri))
|
|
175
|
+
req["Cookie"] = cookie unless cookie.empty?
|
|
176
|
+
req.body = body if body
|
|
177
|
+
req
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def store_cookies(res, uri)
|
|
181
|
+
res.get_fields("set-cookie")&.each do |value|
|
|
182
|
+
HTTP::Cookie.parse(value, uri).each { |c| @cookies.add(c) }
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
@logger&.warn { "[sqrl-client] ignoring unparsable cookie: #{e.message}" }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
data/lib/sqrl/client.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqrl/core"
|
|
4
|
+
require_relative "client/errors"
|
|
5
|
+
require_relative "client/identity"
|
|
6
|
+
require_relative "client/transport"
|
|
7
|
+
require_relative "client/discovery"
|
|
8
|
+
require_relative "client/agent"
|
|
9
|
+
|
|
10
|
+
# SQRL namespace, reopened for the client.
|
|
11
|
+
module SQRL
|
|
12
|
+
# A bot-capable SQRL client: everything a script, a test suite or an automated service
|
|
13
|
+
# needs to log in to a SQRL-enabled website without a browser or a human.
|
|
14
|
+
#
|
|
15
|
+
# identity = SQRL::Client::Identity.load("bot.sqrl", password: ENV["SQRL_PASSWORD"])
|
|
16
|
+
# agent = SQRL::Client::Agent.new(identity: identity)
|
|
17
|
+
# result = agent.login("https://example.com/login")
|
|
18
|
+
# result.authenticated? # => true
|
|
19
|
+
# agent.http.get("https://example.com/account") # carries the logged-in cookies
|
|
20
|
+
module Client
|
|
21
|
+
autoload :Capybara, "sqrl/client/capybara"
|
|
22
|
+
autoload :Conformance, "sqrl/client/conformance"
|
|
23
|
+
autoload :CLI, "sqrl/client/cli"
|
|
24
|
+
|
|
25
|
+
# Version of the client gem (released in lockstep with sqrl-core).
|
|
26
|
+
VERSION = SQRL::VERSION
|
|
27
|
+
end
|
|
28
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sqrl-client
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Daniel P. Clark
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: http-cookie
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: sqrl-core
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 0.1.0
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 0.1.0
|
|
40
|
+
description: Identity management, the full SQRL command set, CPS handoff, login-page
|
|
41
|
+
discovery, a Capybara helper, a CLI and a server conformance suite.
|
|
42
|
+
email:
|
|
43
|
+
- 6ftdan@gmail.com
|
|
44
|
+
executables:
|
|
45
|
+
- sqrl-client
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- CHANGELOG.md
|
|
50
|
+
- LICENSE
|
|
51
|
+
- README.md
|
|
52
|
+
- exe/sqrl-client
|
|
53
|
+
- lib/sqrl/client.rb
|
|
54
|
+
- lib/sqrl/client/agent.rb
|
|
55
|
+
- lib/sqrl/client/capybara.rb
|
|
56
|
+
- lib/sqrl/client/cli.rb
|
|
57
|
+
- lib/sqrl/client/conformance.rb
|
|
58
|
+
- lib/sqrl/client/conformance/report.rb
|
|
59
|
+
- lib/sqrl/client/conformance/rspec.rb
|
|
60
|
+
- lib/sqrl/client/conformance/runner.rb
|
|
61
|
+
- lib/sqrl/client/conformance/site.rb
|
|
62
|
+
- lib/sqrl/client/discovery.rb
|
|
63
|
+
- lib/sqrl/client/errors.rb
|
|
64
|
+
- lib/sqrl/client/identity.rb
|
|
65
|
+
- lib/sqrl/client/transport.rb
|
|
66
|
+
homepage: https://github.com/danielpclark/sqrl-ruby
|
|
67
|
+
licenses:
|
|
68
|
+
- MIT
|
|
69
|
+
metadata:
|
|
70
|
+
homepage_uri: https://github.com/danielpclark/sqrl-ruby
|
|
71
|
+
source_code_uri: https://github.com/danielpclark/sqrl-ruby/tree/main/gems/sqrl-client
|
|
72
|
+
changelog_uri: https://github.com/danielpclark/sqrl-ruby/blob/main/gems/sqrl-client/CHANGELOG.md
|
|
73
|
+
bug_tracker_uri: https://github.com/danielpclark/sqrl-ruby/issues
|
|
74
|
+
documentation_uri: https://github.com/danielpclark/sqrl-ruby/blob/main/gems/sqrl-client/README.md
|
|
75
|
+
rubygems_mfa_required: 'true'
|
|
76
|
+
rdoc_options: []
|
|
77
|
+
require_paths:
|
|
78
|
+
- lib
|
|
79
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '3.1'
|
|
84
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
requirements: []
|
|
90
|
+
rubygems_version: 3.6.9
|
|
91
|
+
specification_version: 4
|
|
92
|
+
summary: 'Bot-capable SQRL client: log in to SQRL-enabled websites from scripts and
|
|
93
|
+
test suites'
|
|
94
|
+
test_files: []
|