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,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Server
5
+ # One SQRL association (an +idk+) known to this site.
6
+ #
7
+ # +user_ref+ is opaque to the protocol: the framework integration uses it to point at
8
+ # its own user record.
9
+ Identity = Struct.new(:idk, :suk, :vuk, :disabled, :sqrlonly, :hardlock, :ins, :logins, :user_ref,
10
+ keyword_init: true) do
11
+ def initialize(idk:, suk:, vuk:, disabled: false, sqrlonly: false, hardlock: false, ins: nil,
12
+ logins: 0, user_ref: nil)
13
+ super
14
+ end
15
+
16
+ def disabled? = disabled ? true : false
17
+ def sqrlonly? = sqrlonly ? true : false
18
+ def hardlock? = hardlock ? true : false
19
+
20
+ # +idk+ as base64url (the wire and storage-friendly form).
21
+ def idk_b64 = Util.b64u_encode(idk)
22
+
23
+ # @return [String] a short description with secret material redacted
24
+ def inspect
25
+ "#<SQRL::Server::Identity idk=#{idk_b64} disabled=#{disabled?} logins=#{logins}>"
26
+ end
27
+ end
28
+
29
+ # A nut issued by the server: one per login page and one per response.
30
+ NutRecord = Struct.new(:nut, :pending_login_id, :expected_server, :ip, :bound_idk, :expires_at, :used_at,
31
+ keyword_init: true) do
32
+ def used? = !used_at.nil?
33
+
34
+ def expired?(now)
35
+ !expires_at.nil? && expires_at <= now
36
+ end
37
+
38
+ # @return [String] a short description with secret material redacted
39
+ def inspect
40
+ "#<SQRL::Server::NutRecord pending_login=#{pending_login_id} used=#{used?}>"
41
+ end
42
+ end
43
+
44
+ # One login attempt as seen from a browser: created when the login page is rendered
45
+ # (its id lives in the browser's session), authenticated by the SQRL client, and
46
+ # completed either through the CPS callback (one-time +cps_token+) or by polling.
47
+ # +ip_matched+ records whether the request that authenticated this login came from
48
+ # the IP that requested the login page (TIF +IPS_MATCHED+). Completions that rely on
49
+ # the browser's own cookie (the poll path) should require it, because a client that
50
+ # sent +noiptest+ may have been tricked into authenticating a login page it did not
51
+ # open; CPS completions do not need it, since the token lands in the client's own
52
+ # browser.
53
+ PendingLogin = Struct.new(:id, :authenticated_idk, :authenticated_at, :btn, :asked, :last_command,
54
+ :cps_token, :cps_token_expires_at, :ip, :return_to, :link_user_ref,
55
+ :created_at, :expires_at, :ip_matched, keyword_init: true) do
56
+ def initialize(id:, ip: nil, return_to: nil, link_user_ref: nil, created_at: nil, expires_at: nil,
57
+ authenticated_idk: nil, authenticated_at: nil, btn: nil, asked: false, last_command: nil,
58
+ cps_token: nil, cps_token_expires_at: nil, ip_matched: false)
59
+ super
60
+ end
61
+
62
+ def authenticated? = !authenticated_idk.nil?
63
+ def asked? = asked ? true : false
64
+
65
+ def expired?(now)
66
+ !expires_at.nil? && expires_at <= now
67
+ end
68
+ # @return [String] a short description with secret material redacted
69
+
70
+ def inspect
71
+ "#<SQRL::Server::PendingLogin id=#{id} authenticated=#{authenticated?}>"
72
+ end
73
+ end
74
+
75
+ # The three stores a {Handler} needs.
76
+ Stores = Struct.new(:nuts, :identities, :pending_logins, keyword_init: true)
77
+
78
+ # Interface for nut storage. Nuts are issued per login page and per response and
79
+ # must be consumed exactly once; +consume+ has to be atomic (compare-and-set or an
80
+ # +UPDATE ... WHERE used_at IS NULL+ that reports whether it changed a row).
81
+ class NutStore
82
+ # Record a freshly issued nut.
83
+ #
84
+ # @param nut [String]
85
+ # @param pending_login_id [String]
86
+ # @param expected_server [String] the exact +server=+ value the next request must echo
87
+ # @param ip [String] client IP that received the nut
88
+ # @param bound_idk [String, nil] identity the transaction is bound to (raw bytes)
89
+ # @param expires_at [Time, nil]
90
+ # @return [NutRecord]
91
+ def issue(nut:, pending_login_id:, expected_server:, ip:, bound_idk:, expires_at:)
92
+ raise NotImplementedError
93
+ end
94
+
95
+ # Atomically mark the nut used and return its record, or nil when it is unknown,
96
+ # already used or expired.
97
+ #
98
+ # @return [NutRecord, nil]
99
+ def consume(nut, now: Time.now)
100
+ raise NotImplementedError
101
+ end
102
+
103
+ # Delete expired and used nuts.
104
+ def prune!(now = Time.now)
105
+ raise NotImplementedError
106
+ end
107
+ end
108
+
109
+ # Interface for identity (SQRL association) storage, keyed by raw +idk+ bytes.
110
+ class IdentityStore
111
+ # @return [Identity, nil]
112
+ def find(idk)
113
+ raise NotImplementedError
114
+ end
115
+
116
+ # @param attrs [Hash] keyword attributes of {Identity}
117
+ # @return [Identity]
118
+ def create(**attrs)
119
+ raise NotImplementedError
120
+ end
121
+
122
+ # Merge +attrs+ into the stored identity.
123
+ #
124
+ # @return [Identity, nil]
125
+ def update(idk, **attrs)
126
+ raise NotImplementedError
127
+ end
128
+
129
+ # Move the association from +old_idk+ to +new_idk+ with the new lock values and
130
+ # mark +old_idk+ superseded.
131
+ #
132
+ # @return [Identity, nil] the re-keyed identity, or nil when +old_idk+ is unknown
133
+ def rekey(old_idk, new_idk, suk:, vuk:, ins:)
134
+ raise NotImplementedError
135
+ end
136
+
137
+ # Whether +idk+ was superseded by a re-key.
138
+ def superseded?(idk)
139
+ raise NotImplementedError
140
+ end
141
+
142
+ # Delete the association. Returns the removed identity or nil.
143
+ def remove(idk)
144
+ raise NotImplementedError
145
+ end
146
+ end
147
+
148
+ # Interface for pending-login storage, keyed by the opaque id kept in the browser
149
+ # session. +claim_cps_token+ must be atomic.
150
+ class PendingLoginStore
151
+ # @return [PendingLogin]
152
+ def create(id, ip:, return_to:, expires_at:, link_user_ref: nil, created_at: Time.now)
153
+ raise NotImplementedError
154
+ end
155
+
156
+ # @return [PendingLogin, nil]
157
+ def find(id)
158
+ raise NotImplementedError
159
+ end
160
+
161
+ # Merge +attrs+ into the pending login.
162
+ #
163
+ # @return [PendingLogin, nil]
164
+ def update(id, **attrs)
165
+ raise NotImplementedError
166
+ end
167
+
168
+ # Atomically clear and return the pending login holding this CPS token, or nil
169
+ # when the token is unknown, already claimed or expired.
170
+ #
171
+ # @return [PendingLogin, nil]
172
+ def claim_cps_token(token, now: Time.now)
173
+ raise NotImplementedError
174
+ end
175
+
176
+ # Delete expired pending logins.
177
+ def prune!(now = Time.now)
178
+ raise NotImplementedError
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,487 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "server/stores"
4
+ require_relative "server/memory"
5
+ require_relative "server/config"
6
+
7
+ module SQRL
8
+ # The server side of the SQRL protocol.
9
+ #
10
+ # {Handler} is a pure function of (nut, form body, client IP, stores, config): it knows
11
+ # nothing about HTTP. Framework integrations route the SQRL endpoint's POST body to
12
+ # {Handler#handle}, render {Handler#new_login_url} on the login page, answer the browser's
13
+ # poll from {Handler#status} and complete a CPS login through {Handler#claim_cps}.
14
+ module Server
15
+ # Raised by a +before_issue+ / +before_handle+ hook to refuse a request (rate limit,
16
+ # block list). HTTP integrations answer 429.
17
+ class RateLimited < SQRL::Error
18
+ # Seconds the client should wait, or nil.
19
+ attr_reader :retry_after
20
+
21
+ def initialize(message = "too many SQRL requests", retry_after: nil)
22
+ super(message)
23
+ @retry_after = retry_after
24
+ end
25
+ end
26
+
27
+ # A freshly issued login URL.
28
+ LoginURL = Struct.new(:url, :nut, :pending_login_id, :cancel_url, keyword_init: true) do
29
+ # The URL base64url-encoded, as the +localhost:25519/<b64>+ CPS link form carries it.
30
+ def b64 = Util.b64u_encode(url.b)
31
+ alias_method :to_s, :url
32
+ end
33
+
34
+ # Outcome of one request to the SQRL endpoint.
35
+ Result = Struct.new(:body, :tif, :command, :idk, :pending_login_id, :pending_login, :authenticated, :created,
36
+ :rekeyed_from, :identity, :cps_url, keyword_init: true) do
37
+ def authenticated? = authenticated ? true : false
38
+ def created? = created ? true : false
39
+ def rekeyed? = !rekeyed_from.nil?
40
+ # @return [String, nil] the identity key as base64url
41
+ def idk_b64 = idk && Util.b64u_encode(idk)
42
+
43
+ # @return [String] a short description with secret material redacted
44
+ def inspect
45
+ "#<SQRL::Server::Result #{command || "?"} tif=0x#{tif.to_hex} authenticated=#{authenticated?}>"
46
+ end
47
+ end
48
+
49
+ # Server-side protocol handler. Port of the reference server, with its state behind
50
+ # the {Stores} interfaces and a one-time CPS token for the callback.
51
+ class Handler
52
+ attr_reader :config, :stores
53
+
54
+ # @param config [Config]
55
+ # @param stores [Stores]
56
+ def initialize(config:, stores: Memory.stores)
57
+ raise ArgumentError, "config must be a SQRL::Server::Config" unless config.is_a?(Config)
58
+
59
+ @config = config
60
+ @stores = stores
61
+ @counter = 0
62
+ @counter_mutex = Mutex.new
63
+ end
64
+
65
+ # Issue a fresh +sqrl://+ login URL for a browser's login page.
66
+ #
67
+ # @param pending_login_id [String, nil] id to keep in the browser session; generated
68
+ # when nil
69
+ # @param ip [String] the browser's IP
70
+ # @param cancel_url [String, nil] URL (or site path) encoded into +can=+
71
+ # @param return_to [String, nil] where to send the browser after login (stored on
72
+ # the pending login for the framework)
73
+ # @param link_user_ref [Object, nil] the signed-in user to link a new identity to
74
+ # @return [LoginURL]
75
+ def new_login_url(pending_login_id = nil, ip:, cancel_url: nil, return_to: nil, link_user_ref: nil)
76
+ now = @config.now
77
+ pending_login_id ||= new_id
78
+ @config.before_issue&.call({ ip: ip.to_s, pending_login_id: pending_login_id, config: @config })
79
+ count_request
80
+ nut = new_nut
81
+ url = "#{@config.scheme}://#{@config.host}#{@config.path}?"
82
+ url << "x=#{@config.path_extension}&" if @config.path_extension
83
+ url << "nut=#{nut}"
84
+ url << "&can=#{Util.b64u_encode(cancel_url.b)}" if cancel_url
85
+ @stores.pending_logins.create(pending_login_id, ip: ip, return_to: return_to, link_user_ref: link_user_ref,
86
+ expires_at: now + @config.nut_ttl, created_at: now)
87
+ @stores.nuts.issue(nut: nut, pending_login_id: pending_login_id, expected_server: Util.b64u_encode(url.b),
88
+ ip: ip, bound_idk: nil, expires_at: now + @config.nut_ttl)
89
+ LoginURL.new(url: url.freeze, nut: nut, pending_login_id: pending_login_id, cancel_url: cancel_url)
90
+ end
91
+
92
+ # State of a pending login, for the browser's poll.
93
+ #
94
+ # @return [Hash] +{ authenticated: Boolean, idk: String (base64url) or nil }+
95
+ def status(pending_login_id)
96
+ login = pending_login_id && @stores.pending_logins.find(pending_login_id)
97
+ return { authenticated: false, idk: nil } if login.nil? || login.expired?(@config.now)
98
+
99
+ { authenticated: login.authenticated?,
100
+ idk: login.authenticated_idk && Util.b64u_encode(login.authenticated_idk) }
101
+ end
102
+
103
+ # Claim a one-time CPS token from the callback URL.
104
+ #
105
+ # @return [PendingLogin, nil] nil when unknown, already used or expired
106
+ def claim_cps(token)
107
+ return nil if token.nil? || token.to_s.empty?
108
+
109
+ @stores.pending_logins.claim_cps_token(token.to_s, now: @config.now)
110
+ end
111
+
112
+ # Handle a POST to the SQRL endpoint.
113
+ #
114
+ # @param nut [String] the +nut=+ from the request URL's query string
115
+ # @param body [String] the form body
116
+ # @param ip [String] the client's address
117
+ # @return [Result] whose +body+ is the response (base64url text)
118
+ def handle(nut:, body:, ip:)
119
+ @config.before_handle&.call({ phase: :request, ip: ip.to_s, nut: nut.to_s, command: nil, idk: nil,
120
+ config: @config })
121
+ count_request
122
+ tx = Transaction.new(self, nut: nut.to_s, body: body.to_s, ip: ip.to_s)
123
+ tx.run
124
+ end
125
+
126
+ # Count one request and prune opportunistically every +config.prune_every+ requests.
127
+ # @api private
128
+ def count_request
129
+ every = @config.prune_every
130
+ return unless every
131
+
132
+ due = @counter_mutex.synchronize do
133
+ @counter += 1
134
+ (@counter % every).zero?
135
+ end
136
+ prune! if due
137
+ end
138
+
139
+ # Delete expired nuts and pending logins.
140
+ def prune!
141
+ now = @config.now
142
+ @stores.nuts.prune!(now)
143
+ @stores.pending_logins.prune!(now)
144
+ nil
145
+ end
146
+
147
+ # A fresh, unpredictable nut (12 random bytes plus a counter).
148
+ # @api private
149
+ def new_nut
150
+ Util.b64u_encode(Util.random_bytes(12))
151
+ end
152
+
153
+ # A fresh pending-login id (random, base64url).
154
+ # @api private
155
+ def new_id
156
+ Util.b64u_encode(Util.random_bytes(18))
157
+ end
158
+
159
+ # A fresh one-time CPS token (32 random bytes, base64url).
160
+ # @api private
161
+ def new_token
162
+ Util.b64u_encode(Util.random_bytes(32))
163
+ end
164
+
165
+ # Parse an +application/x-www-form-urlencoded+ body. SQRL values are base64url, so
166
+ # percent-decoding is only applied to values that actually contain escapes.
167
+ def self.parse_form(body)
168
+ body.to_s.strip.split("&").reject(&:empty?).map do |kv|
169
+ k, v = kv.split("=", 2)
170
+ [k, URL.percent_decode(v || "")]
171
+ end
172
+ end
173
+
174
+ # One request's worth of state. @api private
175
+ class Transaction
176
+ def initialize(handler, nut:, body:, ip:)
177
+ @handler = handler
178
+ @config = handler.config
179
+ @stores = handler.stores
180
+ @nut = nut
181
+ @body = body
182
+ @ip = ip
183
+ @now = @config.now
184
+ @tif = 0
185
+ @extra = []
186
+ @sin = nil
187
+ @result = Result.new(authenticated: false, created: false)
188
+ end
189
+
190
+ # Execute the transaction: nut lookup, verification, command, response.
191
+ # @return [Result]
192
+ def run
193
+ record = @stores.nuts.consume(@nut, now: @now)
194
+ return transient_error unless record
195
+
196
+ @record = record
197
+ # Carry the identity binding from the start so that a malformed request cannot
198
+ # hand out an unbound nut for a transaction that already bound an identity.
199
+ @bound = record.bound_idk
200
+ @login_id = record.pending_login_id
201
+ @login = @stores.pending_logins.find(@login_id)
202
+ return transient_error if @login.nil? || @login.expired?(@now)
203
+
204
+ @result.pending_login_id = @login_id
205
+ @sin = @config.sin_for(context)
206
+
207
+ form = Handler.parse_form(@body)
208
+ get = ->(k) { form.find { |(n, _)| n == k }&.last }
209
+ client_b64 = get.call("client")
210
+ server_b64 = get.call("server")
211
+ ids_b64 = get.call("ids")
212
+ return fail_with(Tif::CLIENT_FAILURE | Tif::COMMAND_FAILED) unless client_b64 && server_b64 && ids_b64
213
+ return fail_with(Tif::CLIENT_FAILURE | Tif::COMMAND_FAILED) unless Util.secure_compare(server_b64,
214
+ record.expected_server)
215
+
216
+ client_text = decode_client(client_b64)
217
+ return fail_with(Tif::CLIENT_FAILURE | Tif::COMMAND_FAILED) unless client_text
218
+
219
+ params = Protocol.parse_pairs(client_text)
220
+ cget = ->(k) { params.find { |(n, _)| n == k }&.last }
221
+
222
+ ver_ok = Protocol.version_supported?(cget.call("ver"), Protocol::VERSION)
223
+ cmd = Command.parse(cget.call("cmd"))
224
+ idk = decode32(cget.call("idk"))
225
+ return fail_with(Tif::CLIENT_FAILURE | Tif::COMMAND_FAILED) unless ver_ok && idk
226
+ return fail_with(Tif::FUNCTION_NOT_SUPPORTED | Tif::COMMAND_FAILED) unless cmd
227
+
228
+ @result.command = cmd
229
+ @result.idk = idk
230
+ @config.before_handle&.call({ phase: :command, ip: @ip, nut: @nut, command: cmd, idk: idk, config: @config })
231
+
232
+ # --- signature verification ---
233
+ signed = (client_b64 + server_b64).b
234
+ ids = decode64(ids_b64)
235
+ sig_ok = ids && Crypto.ed25519_verify(idk, signed, ids)
236
+ pidk = decode32(cget.call("pidk"))
237
+ pids_b64 = get.call("pids")
238
+ pids_ok =
239
+ if pidk && pids_b64
240
+ (pids = decode64(pids_b64)) && Crypto.ed25519_verify(pidk, signed, pids)
241
+ else
242
+ pidk.nil? && pids_b64.nil?
243
+ end
244
+ return fail_with(Tif::CLIENT_FAILURE | Tif::COMMAND_FAILED) unless sig_ok && pids_ok
245
+
246
+ # --- identity binding to the session ---
247
+ if @bound && !Util.secure_compare(@bound, idk)
248
+ return fail_with(Tif::BAD_ID_ASSOCIATION | Tif::COMMAND_FAILED)
249
+ end
250
+
251
+ @bound = idk
252
+ opts = ClientOptions.from_wire(cget.call("opt"))
253
+ if record.ip == @ip
254
+ @tif |= Tif::IPS_MATCHED
255
+ elsif !opts.noiptest? && @config.require_ip_match?
256
+ return fail_with(Tif::COMMAND_FAILED)
257
+ end
258
+
259
+ return fail_with(Tif::IDENTITY_SUPERSEDED | Tif::COMMAND_FAILED) if @stores.identities.superseded?(idk)
260
+
261
+ # --- which identity does the server know? ---
262
+ current = @stores.identities.find(idk)
263
+ previous = pidk && !current ? @stores.identities.find(pidk) : nil
264
+ id_match = !current.nil?
265
+ prev_match = !previous.nil?
266
+ @tif |= Tif::ID_MATCH if id_match
267
+ @tif |= Tif::PREVIOUS_ID_MATCH if prev_match
268
+ matched = current || previous
269
+ @tif |= Tif::SQRL_DISABLED if matched&.disabled?
270
+ @result.identity = matched
271
+
272
+ # ask / btn bookkeeping
273
+ btn = cget.call("btn")&.then { |b| b.match?(/\A\d+\z/) ? b.to_i : nil }
274
+ updates = { last_command: cmd }
275
+ updates[:btn] = btn if btn
276
+ @login = @stores.pending_logins.update(@login_id, **updates)
277
+ ask = @config.ask_for(context(identity: matched, idk: idk))
278
+ ask_pending = !ask.nil? && @login.btn.nil?
279
+ # Answered with a button the site does not accept (for example "Deny"): the
280
+ # transaction cannot authenticate until the client answers again.
281
+ ask_refused = !ask.nil? && !@login.btn.nil? && !ask.accepts?(@login.btn)
282
+
283
+ suk_new = decode32(cget.call("suk"))
284
+ vuk_new = decode32(cget.call("vuk"))
285
+ urs = get.call("urs")&.then { |u| decode64(u) }
286
+ urs_ok = ->(vuk) { !urs.nil? && Crypto.ed25519_verify(vuk, signed, urs) }
287
+
288
+ # --- re-key: previous identity matched and the new keys were supplied ---
289
+ rekey_failed = false
290
+ if prev_match && cmd != Command::QUERY
291
+ ins_ok = @sin.nil? || previous.ins.nil? || Util.secure_compare(previous.ins, cget.call("pins").to_s)
292
+ if suk_new && vuk_new && urs_ok.call(previous.vuk) && ins_ok
293
+ current = @stores.identities.rekey(pidk, idk, suk: suk_new, vuk: vuk_new, ins: cget.call("ins"))
294
+ @result.rekeyed_from = pidk
295
+ @result.identity = current
296
+ matched = current
297
+ id_match = true
298
+ @tif |= Tif::ID_MATCH
299
+ else
300
+ rekey_failed = true
301
+ end
302
+ end
303
+
304
+ suk_to_return = nil
305
+ case cmd
306
+ when Command::QUERY
307
+ if matched && (opts.suk? || matched.disabled? || prev_match)
308
+ suk_to_return = matched.suk
309
+ end
310
+ # An ask is only valid in a query reply that recognises the current identity.
311
+ if ask_pending && id_match
312
+ @extra << ["ask", ask.to_wire]
313
+ @login = @stores.pending_logins.update(@login_id, asked: true)
314
+ end
315
+ when Command::IDENT
316
+ if rekey_failed || (ask_pending && @login.asked?) || ask_refused
317
+ @tif |= Tif::COMMAND_FAILED
318
+ elsif id_match
319
+ if current.disabled?
320
+ @tif |= Tif::COMMAND_FAILED
321
+ else
322
+ attrs = { sqrlonly: opts.sqrlonly?, hardlock: opts.hardlock? }
323
+ if @sin
324
+ ins = cget.call("ins")
325
+ return fail_with(Tif::COMMAND_FAILED) unless ins && (current.ins.nil? || Util.secure_compare(
326
+ current.ins, ins
327
+ ))
328
+
329
+ attrs[:ins] = ins
330
+ end
331
+ attrs[:logins] = current.logins.to_i + 1
332
+ attrs[:user_ref] = @login.link_user_ref if current.user_ref.nil? && @login.link_user_ref
333
+ current = @stores.identities.update(idk, **attrs)
334
+ authenticate!(current, cmd, opts)
335
+ end
336
+ else
337
+ create_account(idk, suk_new, vuk_new, opts, cget.call("ins"), cmd)
338
+ end
339
+ when Command::DISABLE
340
+ if rekey_failed || !id_match
341
+ @tif |= Tif::COMMAND_FAILED
342
+ else
343
+ current = @stores.identities.update(idk, disabled: true)
344
+ @result.identity = current
345
+ @tif |= Tif::SQRL_DISABLED
346
+ end
347
+ when Command::ENABLE
348
+ if rekey_failed || !id_match || ask_refused
349
+ @tif |= Tif::COMMAND_FAILED
350
+ elsif prev_match || urs_ok.call(current.vuk)
351
+ # after a re-key the urs was already verified against the old vuk
352
+ current = @stores.identities.update(idk, disabled: false, logins: current.logins.to_i + 1)
353
+ @tif &= ~Tif::SQRL_DISABLED
354
+ authenticate!(current, cmd, opts)
355
+ else
356
+ @tif |= Tif::COMMAND_FAILED | Tif::CLIENT_FAILURE
357
+ end
358
+ when Command::REMOVE
359
+ if rekey_failed || !id_match
360
+ @tif |= Tif::COMMAND_FAILED
361
+ elsif prev_match || urs_ok.call(current.vuk)
362
+ @stores.identities.remove(idk)
363
+ @result.identity = nil
364
+ @tif &= ~(Tif::ID_MATCH | Tif::SQRL_DISABLED)
365
+ @login = @stores.pending_logins.update(@login_id, authenticated_idk: nil, authenticated_at: nil)
366
+ issue_cps_url if opts.cps?
367
+ else
368
+ @tif |= Tif::COMMAND_FAILED | Tif::CLIENT_FAILURE
369
+ end
370
+ end
371
+
372
+ @extra << ["suk", Util.b64u_encode(suk_to_return)] if suk_to_return
373
+ respond
374
+ end
375
+
376
+ private
377
+
378
+ def context(identity: nil, idk: nil)
379
+ { pending_login: @login, pending_login_id: @login_id, ip: @ip, identity: identity, idk: idk,
380
+ config: @config }
381
+ end
382
+
383
+ def decode_client(b64)
384
+ text = Util.b64u_decode(b64).force_encoding(Encoding::UTF_8)
385
+ text.valid_encoding? ? text : nil
386
+ rescue FormatError
387
+ nil
388
+ end
389
+
390
+ def decode32(b64)
391
+ b64 && Util.b64u_decode_fixed(b64, 32)
392
+ rescue FormatError
393
+ nil
394
+ end
395
+
396
+ def decode64(b64)
397
+ b64 && Util.b64u_decode_fixed(b64, 64)
398
+ rescue FormatError
399
+ nil
400
+ end
401
+
402
+ def create_account(idk, suk, vuk, opts, ins, cmd)
403
+ unless suk && vuk
404
+ @tif |= Tif::COMMAND_FAILED | Tif::CLIENT_FAILURE
405
+ return
406
+ end
407
+ user_ref = @login.link_user_ref
408
+ case @config.on_unknown_identity
409
+ when :refuse
410
+ @tif |= Tif::COMMAND_FAILED
411
+ return
412
+ when :link
413
+ if user_ref.nil?
414
+ @tif |= Tif::COMMAND_FAILED
415
+ return
416
+ end
417
+ end
418
+ identity = @stores.identities.create(idk: idk, suk: suk, vuk: vuk, disabled: false,
419
+ sqrlonly: opts.sqrlonly?, hardlock: opts.hardlock?,
420
+ ins: ins, logins: 1, user_ref: user_ref)
421
+ @tif |= Tif::ID_MATCH
422
+ @result.created = true
423
+ authenticate!(identity, cmd, opts)
424
+ end
425
+
426
+ def authenticate!(identity, cmd, opts)
427
+ @result.identity = identity
428
+ @result.authenticated = true
429
+ @login = @stores.pending_logins.update(@login_id, authenticated_idk: identity.idk, authenticated_at: @now,
430
+ ip_matched: @tif.anybits?(Tif::IPS_MATCHED))
431
+ issue_cps_url if opts.cps?
432
+ @config.on_authenticated&.call(identity, @login, cmd)
433
+ end
434
+
435
+ def issue_cps_url
436
+ token = @handler.new_token
437
+ @login = @stores.pending_logins.update(@login_id, cps_token: token,
438
+ cps_token_expires_at: @now + @config.cps_token_ttl)
439
+ url = @config.cps_url_for(token, @login)
440
+ @result.cps_url = url
441
+ @extra << ["url", url]
442
+ end
443
+
444
+ # Unknown or reused nut: transient error, hand out a fresh nut bound to a new
445
+ # anonymous pending login so the client can retry.
446
+ def transient_error
447
+ @tif |= Tif::TRANSIENT_ERROR | Tif::COMMAND_FAILED
448
+ @login_id = @handler.new_id
449
+ @login = @stores.pending_logins.create(@login_id, ip: @ip, return_to: nil,
450
+ expires_at: @now + @config.nut_ttl, created_at: @now)
451
+ @result.pending_login_id = @login_id
452
+ @bound = nil
453
+ @sin = @config.sin_for(context)
454
+ respond
455
+ end
456
+
457
+ def fail_with(bits)
458
+ @tif |= bits
459
+ respond
460
+ end
461
+
462
+ def respond
463
+ nut = @handler.new_nut
464
+ text = "ver=#{Protocol::VERSION}\r\nnut=#{nut}\r\ntif=#{Tif.new(@tif).to_hex}\r\nqry=#{@config.path}?nut=#{nut}\r\n"
465
+ text << "sin=#{@sin}\r\n" if @sin
466
+ @extra.each { |k, v| text << "#{k}=#{v}\r\n" }
467
+ body = Util.b64u_encode(text.b)
468
+ @stores.nuts.issue(nut: nut, pending_login_id: @login_id, expected_server: body,
469
+ ip: @record ? @record.ip : @ip, bound_idk: @bound, expires_at: @now + @config.nut_ttl)
470
+ @result.body = body
471
+ @result.tif = Tif.new(@tif)
472
+ @result.pending_login = @login
473
+ log
474
+ @result
475
+ end
476
+
477
+ def log
478
+ logger = @config.logger
479
+ return unless logger.respond_to?(:debug)
480
+
481
+ logger.debug("[sqrl] #{@result.command || "invalid"} tif=0x#{@result.tif.to_hex} " \
482
+ "login=#{@login_id} authenticated=#{@result.authenticated?}")
483
+ end
484
+ end
485
+ end
486
+ end
487
+ end