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.
@@ -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