sqrl-core 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 +21 -0
- data/LICENSE +21 -0
- data/README.md +164 -0
- data/lib/sqrl/core.rb +6 -0
- data/lib/sqrl/crypto.rb +166 -0
- data/lib/sqrl/errors.rb +15 -0
- data/lib/sqrl/identity.rb +143 -0
- data/lib/sqrl/protocol/session.rb +396 -0
- data/lib/sqrl/protocol.rb +433 -0
- data/lib/sqrl/server/config.rb +255 -0
- data/lib/sqrl/server/memory.rb +187 -0
- data/lib/sqrl/server/redis.rb +420 -0
- data/lib/sqrl/server/stores.rb +182 -0
- data/lib/sqrl/server.rb +487 -0
- data/lib/sqrl/storage.rb +624 -0
- data/lib/sqrl/test_vectors.rb +152 -0
- data/lib/sqrl/textual.rb +124 -0
- data/lib/sqrl/url.rb +261 -0
- data/lib/sqrl/util.rb +114 -0
- data/lib/sqrl/version.rb +6 -0
- data/lib/sqrl.rb +24 -0
- metadata +70 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
# Drives one SQRL transaction for one unlocked identity against one +sqrl://+ URL.
|
|
5
|
+
#
|
|
6
|
+
# The session builds signed {Request}s (+query+, +ident+, +disable+, +enable+, +remove+)
|
|
7
|
+
# and consumes the server's responses with {#feed}. It performs no I/O itself: the caller
|
|
8
|
+
# POSTs +request.body+ to +request.url+ with
|
|
9
|
+
# +Content-Type: application/x-www-form-urlencoded+ and feeds the response body back.
|
|
10
|
+
class Session
|
|
11
|
+
# A request ready to be POSTed.
|
|
12
|
+
Request = Struct.new(:url, :body, :command, :client_text, keyword_init: true) do
|
|
13
|
+
# @return [String] a short description with secret material redacted
|
|
14
|
+
def inspect
|
|
15
|
+
"#<SQRL::Session::Request #{command} -> #{url}>"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# High-level summary of where a transaction stands, derived from the last response.
|
|
20
|
+
State = Struct.new(:kind, :disabled, :message, keyword_init: true) do
|
|
21
|
+
def initialize(kind:, disabled: false, message: nil)
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
%i[no_response unknown known previous_known transient superseded failed].each do |k|
|
|
26
|
+
define_method(:"#{k}?") { kind == k }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def disabled? = disabled ? true : false
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] whether +other+ carries the same values
|
|
32
|
+
def ==(other)
|
|
33
|
+
case other
|
|
34
|
+
when Symbol then kind == other
|
|
35
|
+
when State then to_h == other.to_h
|
|
36
|
+
else false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [String] a short description with secret material redacted
|
|
41
|
+
def inspect
|
|
42
|
+
extra = disabled ? " disabled" : ""
|
|
43
|
+
extra += " (#{message})" if message
|
|
44
|
+
"#<SQRL::Session::State #{kind}#{extra}>"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attr_reader :url, :last_command
|
|
49
|
+
|
|
50
|
+
# Whether plain-http (+qrl://+) URLs were accepted for this session.
|
|
51
|
+
def insecure? = @insecure
|
|
52
|
+
|
|
53
|
+
# @param identity [UnlockedIdentity]
|
|
54
|
+
# @param url [URL]
|
|
55
|
+
# @param options [ClientOptions]
|
|
56
|
+
# @param alt_id [String, nil] the optional "Alt-ID" selecting a different identity for
|
|
57
|
+
# the same site
|
|
58
|
+
# @param lock_factory [#call, nil] overrides SUK/VUK generation (tests only); receives
|
|
59
|
+
# the ILK and returns an {Identity::ServerLock}
|
|
60
|
+
# +insecure: true+ accepts a +qrl://+ (plain http, loopback-only) URL, which is not part
|
|
61
|
+
# of the final SQRL specification; by default such URLs are refused.
|
|
62
|
+
def initialize(identity, url, options: ClientOptions.new, alt_id: nil, lock_factory: nil, insecure: false)
|
|
63
|
+
raise ArgumentError, "identity must be an UnlockedIdentity" unless identity.is_a?(UnlockedIdentity)
|
|
64
|
+
|
|
65
|
+
url = URL.parse(url) unless url.is_a?(URL)
|
|
66
|
+
if url.scheme == "qrl" && !insecure
|
|
67
|
+
raise ProtocolError, "qrl:// (plain http) URLs are not part of SQRL; pass insecure: true to accept " \
|
|
68
|
+
"them for loopback testing"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@insecure = insecure ? true : false
|
|
72
|
+
@identity = identity
|
|
73
|
+
@url = url
|
|
74
|
+
@site_key = url.site_key_bytes(alt_id).freeze
|
|
75
|
+
@options = options.dup
|
|
76
|
+
@lock_factory = lock_factory || ->(ilk) { Identity.make_server_lock(ilk) }
|
|
77
|
+
@prev_index = 0
|
|
78
|
+
@last = nil
|
|
79
|
+
@last_command = nil
|
|
80
|
+
@pending_btn = nil
|
|
81
|
+
@answered_ask = false
|
|
82
|
+
@last_lock = nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Options in use (a copy; use {#options=} to change them).
|
|
86
|
+
def options
|
|
87
|
+
@options.dup
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Change options for subsequent requests.
|
|
91
|
+
def options=(options)
|
|
92
|
+
@options = options.dup
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The most recent server response, if any.
|
|
96
|
+
def last_response
|
|
97
|
+
@last
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Per-site public key (+idk+) of the current identity, as raw bytes.
|
|
101
|
+
def idk
|
|
102
|
+
@identity.site_public_key(@site_key)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# +idk+ as base64url, the form it has on the wire.
|
|
106
|
+
def idk_b64
|
|
107
|
+
Util.b64u_encode(idk)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Whether the identity has any previous identities to offer as +pidk+.
|
|
111
|
+
def has_previous?
|
|
112
|
+
@identity.previous_count.positive?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [Integer] number of previous identities the identity carries
|
|
116
|
+
def previous_count
|
|
117
|
+
@identity.previous_count
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Index of the previous identity currently offered as +pidk+.
|
|
121
|
+
attr_reader :prev_index
|
|
122
|
+
alias previous_index prev_index
|
|
123
|
+
|
|
124
|
+
# Move on to the next older previous identity. Returns false when none remain.
|
|
125
|
+
def try_next_previous
|
|
126
|
+
return false unless @prev_index + 1 < @identity.previous_count
|
|
127
|
+
|
|
128
|
+
@prev_index += 1
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Record the user's answer to the server's +ask=+: 0 for the plain "OK" (shown when the
|
|
133
|
+
# ask carries no buttons), 1 or 2 for the first or second button. Cancelling the ask
|
|
134
|
+
# means not sending any further command at all, so there is no value for it.
|
|
135
|
+
def set_button(btn)
|
|
136
|
+
@pending_btn = btn.to_i.clamp(0, 2)
|
|
137
|
+
@answered_ask = true
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The server's pending question, if the last response carried one and it has not been
|
|
141
|
+
# answered yet. Per the specification an +ask=+ is only honoured in the reply to a
|
|
142
|
+
# +query+ whose TIF has the current-ID-match bit set; anywhere else it is ignored.
|
|
143
|
+
def pending_ask
|
|
144
|
+
return nil if @answered_ask || @last_command != Command::QUERY
|
|
145
|
+
return nil unless @last
|
|
146
|
+
return nil if !@last.tif.id_match? || @last.tif.command_failed?
|
|
147
|
+
|
|
148
|
+
@last.ask
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The URL bound to ask button +index+ (0-based), if the server attached one. The server
|
|
152
|
+
# may only supply a root-anchored path; it is forced to start with +/+ and is resolved
|
|
153
|
+
# against this site's scheme, host and port, so it can never point elsewhere.
|
|
154
|
+
def ask_button_url(index)
|
|
155
|
+
ask = @last&.ask
|
|
156
|
+
raw = ask&.buttons&.[](index)&.url&.strip
|
|
157
|
+
return nil if raw.nil? || raw.empty?
|
|
158
|
+
|
|
159
|
+
"#{@url.origin}/#{raw.sub(%r{\A/+}, "")}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Summary of the last response.
|
|
163
|
+
#
|
|
164
|
+
# @return [State]
|
|
165
|
+
def state
|
|
166
|
+
return State.new(kind: :no_response) unless @last
|
|
167
|
+
|
|
168
|
+
t = @last.tif
|
|
169
|
+
return State.new(kind: :superseded) if t.identity_superseded?
|
|
170
|
+
return State.new(kind: :transient) if t.transient_error?
|
|
171
|
+
if t.client_failure?
|
|
172
|
+
return State.new(kind: :failed, message: "the server rejected the request (client failure)")
|
|
173
|
+
end
|
|
174
|
+
if t.bad_id_association?
|
|
175
|
+
return State.new(kind: :failed, message: "identity does not match the one that started this session")
|
|
176
|
+
end
|
|
177
|
+
if t.function_not_supported?
|
|
178
|
+
return State.new(kind: :failed, message: "the server does not support that command")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if t.command_failed?
|
|
182
|
+
if t.value == Tif::COMMAND_FAILED
|
|
183
|
+
return State.new(kind: :failed,
|
|
184
|
+
message: "the server refused the request, most likely because this connection's " \
|
|
185
|
+
"IP address differs from the one that requested the login page")
|
|
186
|
+
end
|
|
187
|
+
return State.new(kind: :failed, message: "the server could not perform the command")
|
|
188
|
+
end
|
|
189
|
+
if t.id_match?
|
|
190
|
+
State.new(kind: :known, disabled: t.sqrl_disabled?)
|
|
191
|
+
elsif t.previous_id_match?
|
|
192
|
+
State.new(kind: :previous_known, disabled: t.sqrl_disabled?)
|
|
193
|
+
else
|
|
194
|
+
State.new(kind: :unknown)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The CPS success URL from the last response, validated to point at this site.
|
|
199
|
+
#
|
|
200
|
+
# @return [String, nil]
|
|
201
|
+
# @raise [ProtocolError] when +url=+ points elsewhere
|
|
202
|
+
def cps_url
|
|
203
|
+
u = @last&.url
|
|
204
|
+
return nil unless u
|
|
205
|
+
|
|
206
|
+
resolved = @url.resolve(u)
|
|
207
|
+
raise ProtocolError, "CPS url '#{u}' does not point at #{@url.host}" unless @url.same_host?(resolved)
|
|
208
|
+
|
|
209
|
+
resolved
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The cancel URL: the server's most recent +can=+ reply parameter (a plain URL or
|
|
213
|
+
# root-relative path) if any, else the base64url-encoded +can=+ of the SQRL URL.
|
|
214
|
+
#
|
|
215
|
+
# A cancel URL that does not point at this site (another host, or plain http for a
|
|
216
|
+
# sqrl:// site) is ignored and +nil+ is returned, so a tampered reply cannot send the
|
|
217
|
+
# browser elsewhere.
|
|
218
|
+
def cancel_url
|
|
219
|
+
c = @last&.can
|
|
220
|
+
resolved = c ? @url.resolve(c) : @url.cancel_url
|
|
221
|
+
return nil unless resolved
|
|
222
|
+
return resolved if @url.same_host?(resolved)
|
|
223
|
+
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Build the initial (or a repeated) +query+, offering the current previous identity.
|
|
228
|
+
def query
|
|
229
|
+
build(Command::QUERY, include_pidk: true)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Build an +ident+: log in, create the account, or re-key it, depending on the last
|
|
233
|
+
# query's answer.
|
|
234
|
+
def ident
|
|
235
|
+
require_response!("ident")
|
|
236
|
+
if needs_rekey?
|
|
237
|
+
lock, urs = rekey_material
|
|
238
|
+
build(Command::IDENT, include_pidk: true, lock: lock, urs_seed: urs)
|
|
239
|
+
elsif !last_tif.id_match?
|
|
240
|
+
build(Command::IDENT, lock: @lock_factory.call(@identity.ilk))
|
|
241
|
+
else
|
|
242
|
+
build(Command::IDENT)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Build a +disable+.
|
|
247
|
+
def disable
|
|
248
|
+
require_response!("disable")
|
|
249
|
+
if needs_rekey?
|
|
250
|
+
lock, urs = rekey_material
|
|
251
|
+
build(Command::DISABLE, include_pidk: true, lock: lock, urs_seed: urs)
|
|
252
|
+
else
|
|
253
|
+
build(Command::DISABLE)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Build an +enable+. +iuk+ is required unless only a previous identity matched.
|
|
258
|
+
def enable(iuk = nil)
|
|
259
|
+
require_response!("enable")
|
|
260
|
+
lock, urs = unlock_material(iuk)
|
|
261
|
+
build(Command::ENABLE, include_pidk: !lock.nil?, lock: lock, urs_seed: urs)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Build a +remove+. +iuk+ is required unless only a previous identity matched.
|
|
265
|
+
def remove(iuk = nil)
|
|
266
|
+
require_response!("remove")
|
|
267
|
+
lock, urs = unlock_material(iuk)
|
|
268
|
+
build(Command::REMOVE, include_pidk: !lock.nil?, lock: lock, urs_seed: urs)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Feed the body of the server's HTTP response. Validates it and makes it the basis of
|
|
272
|
+
# the next request.
|
|
273
|
+
#
|
|
274
|
+
# @return [ServerResponse]
|
|
275
|
+
# @raise [ProtocolError, FormatError]
|
|
276
|
+
def feed(body)
|
|
277
|
+
resp = ServerResponse.parse(body)
|
|
278
|
+
# qry must stay on this host
|
|
279
|
+
@url.post_url_for_qry(resp.qry)
|
|
280
|
+
@answered_ask = false if resp.ask
|
|
281
|
+
@last = resp
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Whether the last reply was the "exactly 0x40" a server returns when the query IP does
|
|
285
|
+
# not match the IP that requested the SQRL URL and +noiptest+ was not offered.
|
|
286
|
+
def ip_check_failed?
|
|
287
|
+
!@last.nil? && @last.tif.value == Tif::COMMAND_FAILED
|
|
288
|
+
end
|
|
289
|
+
# @return [String] a short description with secret material redacted
|
|
290
|
+
|
|
291
|
+
def inspect
|
|
292
|
+
"#<SQRL::Session #{@url.host} state=#{state.kind}>"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
private
|
|
296
|
+
|
|
297
|
+
def require_response!(command)
|
|
298
|
+
raise ProtocolError, "send a query before #{command}" if @last.nil?
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def last_tif
|
|
302
|
+
@last ? @last.tif : Tif.new(0)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Whether the server's last answer says the account is still bound to the previous
|
|
306
|
+
# identity (and must therefore be re-keyed by the next command).
|
|
307
|
+
def needs_rekey?
|
|
308
|
+
t = last_tif
|
|
309
|
+
t.previous_id_match? && !t.id_match?
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Lock material and +urs+ seed for a re-key (previous identity matched).
|
|
313
|
+
def rekey_material
|
|
314
|
+
suk = @last&.suk
|
|
315
|
+
raise ProtocolError, "server matched a previous identity but sent no suk" unless suk
|
|
316
|
+
|
|
317
|
+
urs_seed = @identity.previous_unlock_request_seed(@prev_index, suk)
|
|
318
|
+
raise ProtocolError, "no such previous identity" unless urs_seed
|
|
319
|
+
|
|
320
|
+
[@lock_factory.call(@identity.ilk), urs_seed]
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# +urs+ seed for +enable+/+remove+: the current IUK (from the rescue code) when the
|
|
324
|
+
# current identity matched, or the previous IUK when only a previous identity matched.
|
|
325
|
+
def unlock_material(iuk)
|
|
326
|
+
return rekey_material if needs_rekey?
|
|
327
|
+
|
|
328
|
+
suk = @last&.suk
|
|
329
|
+
raise ProtocolError, "the server did not provide the account's suk" unless suk
|
|
330
|
+
raise ProtocolError, "this operation needs the rescue code (IUK)" unless iuk
|
|
331
|
+
|
|
332
|
+
[nil, Identity.unlock_request_seed(iuk, suk)]
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def server_param
|
|
336
|
+
@last ? @last.raw : Util.b64u_encode(@url.original.b)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def post_url
|
|
340
|
+
@last ? @url.post_url_for_qry(@last.qry) : @url.post_url
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Build and sign a request.
|
|
344
|
+
def build(command, include_pidk: false, lock: nil, urs_seed: nil)
|
|
345
|
+
url = post_url
|
|
346
|
+
server = server_param
|
|
347
|
+
|
|
348
|
+
client = "ver=#{Protocol::VERSION}\r\n"
|
|
349
|
+
client << "cmd=#{Command.to_wire(command)}\r\n"
|
|
350
|
+
client << "idk=#{idk_b64}\r\n"
|
|
351
|
+
pseed = nil
|
|
352
|
+
if include_pidk && has_previous?
|
|
353
|
+
pseed = @identity.previous_site_seed(@prev_index, @site_key)
|
|
354
|
+
raise ProtocolError, "no such previous identity" unless pseed
|
|
355
|
+
|
|
356
|
+
client << "pidk=#{Util.b64u_encode(Crypto.ed25519_public(pseed))}\r\n"
|
|
357
|
+
end
|
|
358
|
+
if lock
|
|
359
|
+
client << "suk=#{Util.b64u_encode(lock.suk)}\r\n"
|
|
360
|
+
client << "vuk=#{Util.b64u_encode(lock.vuk)}\r\n"
|
|
361
|
+
end
|
|
362
|
+
# The SUK is requested with every command (as GRC's reference client does) so that a
|
|
363
|
+
# later enable/remove always has it. sqrlonly/hardlock only make sense while an
|
|
364
|
+
# association exists, so they are never sent with remove.
|
|
365
|
+
opts = @options.dup
|
|
366
|
+
opts.suk = true
|
|
367
|
+
if command == Command::REMOVE
|
|
368
|
+
opts.sqrlonly = false
|
|
369
|
+
opts.hardlock = false
|
|
370
|
+
end
|
|
371
|
+
opt = opts.to_wire
|
|
372
|
+
client << "opt=#{opt}\r\n" if opt
|
|
373
|
+
if @pending_btn
|
|
374
|
+
client << "btn=#{@pending_btn}\r\n"
|
|
375
|
+
@pending_btn = nil
|
|
376
|
+
end
|
|
377
|
+
if (sin = @last&.sin)
|
|
378
|
+
seed = @identity.site_seed(@site_key)
|
|
379
|
+
client << "ins=#{Util.b64u_encode(Identity.indexed_secret(seed, sin.b))}\r\n"
|
|
380
|
+
client << "pins=#{Util.b64u_encode(Identity.indexed_secret(pseed, sin.b))}\r\n" if pseed
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
client_b64 = Util.b64u_encode(client.b)
|
|
384
|
+
msg = (client_b64 + server).b
|
|
385
|
+
|
|
386
|
+
ids = Crypto.ed25519_sign(@identity.site_seed(@site_key), msg)
|
|
387
|
+
body = "client=#{client_b64}&server=#{server}&ids=#{Util.b64u_encode(ids)}"
|
|
388
|
+
body << "&pids=#{Util.b64u_encode(Crypto.ed25519_sign(pseed, msg))}" if pseed
|
|
389
|
+
body << "&urs=#{Util.b64u_encode(Crypto.ed25519_sign(urs_seed, msg))}" if urs_seed
|
|
390
|
+
|
|
391
|
+
@last_lock = lock
|
|
392
|
+
@last_command = command
|
|
393
|
+
Request.new(url: url, body: body.freeze, command: command, client_text: client.freeze)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|