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,433 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
# The SQRL client <-> server wire protocol: shared types and parsing.
|
|
5
|
+
#
|
|
6
|
+
# A transaction is a sequence of HTTPS POSTs. Every POST body is
|
|
7
|
+
# +client=<b64u>&server=<b64u>&ids=<b64u>[&pids=<b64u>][&urs=<b64u>]+ where
|
|
8
|
+
#
|
|
9
|
+
# * +client+ is a CRLF-separated list of +name=value+ pairs (+ver+, +cmd+, +idk+, ...),
|
|
10
|
+
# * +server+ is, for the first request, the complete +sqrl://+ URL as received, and for
|
|
11
|
+
# every following request the server's previous response *verbatim*,
|
|
12
|
+
# * +ids+ is the Ed25519 signature (per-site identity key) over the concatenation of the
|
|
13
|
+
# +client+ and +server+ base64url strings; +pids+ the same with a previous identity key;
|
|
14
|
+
# +urs+ the same with the Unlock Request Signing Key.
|
|
15
|
+
#
|
|
16
|
+
# The server answers with a base64url-encoded CRLF list containing at least +ver+, +nut+,
|
|
17
|
+
# +tif+ (hex transaction information flags) and +qry+ (the path to POST the next request
|
|
18
|
+
# to).
|
|
19
|
+
module Protocol
|
|
20
|
+
# Protocol version this implementation speaks.
|
|
21
|
+
VERSION = 1
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Parse a CRLF-separated +name=value+ list (LF-only input is tolerated).
|
|
26
|
+
#
|
|
27
|
+
# @return [Array<Array(String, String)>] pairs in order
|
|
28
|
+
def parse_pairs(text)
|
|
29
|
+
text.to_s.split("\r\n").flat_map { |l| l.split("\n") }.filter_map do |line|
|
|
30
|
+
next if line.strip.empty?
|
|
31
|
+
|
|
32
|
+
k, v = line.split("=", 2)
|
|
33
|
+
v ? [k.strip, v.chomp("\r")] : [line.strip, ""]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether a +ver=+ value (e.g. +1+, +1,2+, +1-3,5+) includes a given version.
|
|
38
|
+
def version_supported?(ver, wanted = VERSION)
|
|
39
|
+
ver.to_s.split(",").any? do |part|
|
|
40
|
+
part = part.strip
|
|
41
|
+
if part.include?("-")
|
|
42
|
+
a, b = part.split("-", 2).map(&:strip)
|
|
43
|
+
a.match?(/\A\d+\z/) && b.match?(/\A\d+\z/) && a.to_i <= wanted && wanted <= b.to_i
|
|
44
|
+
else
|
|
45
|
+
part.match?(/\A\d+\z/) && part.to_i == wanted
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Decode a base64url text parameter into a UTF-8 string.
|
|
51
|
+
def decode_text(b64)
|
|
52
|
+
text = Util.b64u_decode(b64).force_encoding(Encoding::UTF_8)
|
|
53
|
+
raise FormatError, "text is not UTF-8" unless text.valid_encoding?
|
|
54
|
+
|
|
55
|
+
text
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Transaction Information Flags returned by the server. Immutable value object.
|
|
60
|
+
class Tif
|
|
61
|
+
# The current identity (+idk+) is known to the server.
|
|
62
|
+
ID_MATCH = 0x01
|
|
63
|
+
# The previous identity (+pidk+) is known to the server.
|
|
64
|
+
PREVIOUS_ID_MATCH = 0x02
|
|
65
|
+
# The IP of this request matches the IP that requested the nut.
|
|
66
|
+
IPS_MATCHED = 0x04
|
|
67
|
+
# The account has SQRL authentication disabled.
|
|
68
|
+
SQRL_DISABLED = 0x08
|
|
69
|
+
# The server does not support the requested command / function.
|
|
70
|
+
FUNCTION_NOT_SUPPORTED = 0x10
|
|
71
|
+
# Transient error (e.g. stale nut): the client may retry with the new nut.
|
|
72
|
+
TRANSIENT_ERROR = 0x20
|
|
73
|
+
# The command could not be executed.
|
|
74
|
+
COMMAND_FAILED = 0x40
|
|
75
|
+
# The request was malformed or failed verification.
|
|
76
|
+
CLIENT_FAILURE = 0x80
|
|
77
|
+
# The identity does not match the one that started this transaction.
|
|
78
|
+
BAD_ID_ASSOCIATION = 0x100
|
|
79
|
+
# The identity has been superseded by a newer (re-keyed) identity.
|
|
80
|
+
IDENTITY_SUPERSEDED = 0x200
|
|
81
|
+
# Every bit defined by protocol version 1. "All SQRL clients MUST immediately terminate
|
|
82
|
+
# any connection and abort any authentication operation with any SQRL server which
|
|
83
|
+
# includes TIF bits not defined" (On The Wire, tif).
|
|
84
|
+
DEFINED_BITS = 0x3FF
|
|
85
|
+
# Every TIF bit that means the command did not succeed.
|
|
86
|
+
ERROR_BITS = FUNCTION_NOT_SUPPORTED | TRANSIENT_ERROR | COMMAND_FAILED | CLIENT_FAILURE |
|
|
87
|
+
BAD_ID_ASSOCIATION | IDENTITY_SUPERSEDED
|
|
88
|
+
|
|
89
|
+
attr_reader :value
|
|
90
|
+
|
|
91
|
+
def initialize(value = 0)
|
|
92
|
+
@value = Integer(value)
|
|
93
|
+
freeze
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Parse the hexadecimal +tif=+ value.
|
|
97
|
+
#
|
|
98
|
+
# @raise [FormatError]
|
|
99
|
+
def self.parse(hex)
|
|
100
|
+
str = hex.to_s.strip
|
|
101
|
+
raise FormatError, "invalid tif '#{hex}'" unless str.match?(/\A[0-9a-fA-F]+\z/)
|
|
102
|
+
|
|
103
|
+
new(str.hex)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Tif] +value+ wrapped (a Tif is returned unchanged)
|
|
107
|
+
def self.[](value)
|
|
108
|
+
value.is_a?(Tif) ? value : new(value)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Whether only version-1 bits are set.
|
|
112
|
+
def defined?
|
|
113
|
+
@value.nobits?(~DEFINED_BITS)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Test one or more flags.
|
|
117
|
+
def has?(flag)
|
|
118
|
+
@value.anybits?(flag)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def id_match? = has?(ID_MATCH)
|
|
122
|
+
def previous_id_match? = has?(PREVIOUS_ID_MATCH)
|
|
123
|
+
def ips_matched? = has?(IPS_MATCHED)
|
|
124
|
+
def sqrl_disabled? = has?(SQRL_DISABLED)
|
|
125
|
+
def function_not_supported? = has?(FUNCTION_NOT_SUPPORTED)
|
|
126
|
+
def transient_error? = has?(TRANSIENT_ERROR)
|
|
127
|
+
def command_failed? = has?(COMMAND_FAILED)
|
|
128
|
+
def client_failure? = has?(CLIENT_FAILURE)
|
|
129
|
+
def bad_id_association? = has?(BAD_ID_ASSOCIATION)
|
|
130
|
+
def identity_superseded? = has?(IDENTITY_SUPERSEDED)
|
|
131
|
+
|
|
132
|
+
# Any of the error flags.
|
|
133
|
+
def error?
|
|
134
|
+
has?(ERROR_BITS)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Lower-case hex as sent on the wire.
|
|
138
|
+
def to_hex
|
|
139
|
+
@value.to_s(16)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [Tif] bitwise OR with another Tif or Integer
|
|
143
|
+
def |(other)
|
|
144
|
+
Tif.new(@value | Tif[other].value)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [Tif] bitwise AND with another Tif or Integer
|
|
148
|
+
def &(other)
|
|
149
|
+
Tif.new(@value & Tif[other].value)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Clear the given bits.
|
|
153
|
+
def without(flag)
|
|
154
|
+
Tif.new(@value & ~flag)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [Integer] the raw flag bits
|
|
158
|
+
def to_i
|
|
159
|
+
@value
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @return [Boolean] whether +other+ carries the same values
|
|
163
|
+
def ==(other)
|
|
164
|
+
other.is_a?(Tif) ? other.value == @value : other == @value
|
|
165
|
+
end
|
|
166
|
+
alias eql? ==
|
|
167
|
+
|
|
168
|
+
# @return [Integer] hash consistent with {#==}
|
|
169
|
+
def hash
|
|
170
|
+
@value.hash
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @return [String] a short description with secret material redacted
|
|
174
|
+
def inspect
|
|
175
|
+
"#<SQRL::Tif 0x#{to_hex}>"
|
|
176
|
+
end
|
|
177
|
+
alias to_s to_hex
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Client commands.
|
|
181
|
+
module Command
|
|
182
|
+
# Ask whether the server knows this identity.
|
|
183
|
+
QUERY = :query
|
|
184
|
+
# Authenticate: log in, create the account or re-key it.
|
|
185
|
+
IDENT = :ident
|
|
186
|
+
# Disable SQRL authentication for the account.
|
|
187
|
+
DISABLE = :disable
|
|
188
|
+
# Re-enable SQRL authentication (needs +urs+).
|
|
189
|
+
ENABLE = :enable
|
|
190
|
+
# Remove the SQRL association (needs +urs+).
|
|
191
|
+
REMOVE = :remove
|
|
192
|
+
# Every command, in wire order.
|
|
193
|
+
ALL = [QUERY, IDENT, DISABLE, ENABLE, REMOVE].freeze
|
|
194
|
+
|
|
195
|
+
module_function
|
|
196
|
+
|
|
197
|
+
# Parse the wire name; returns nil for anything unknown.
|
|
198
|
+
def parse(str)
|
|
199
|
+
sym = str.to_s.to_sym
|
|
200
|
+
ALL.include?(sym) ? sym : nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# The wire name.
|
|
204
|
+
def to_wire(command)
|
|
205
|
+
raise ArgumentError, "unknown command #{command.inspect}" unless ALL.include?(command)
|
|
206
|
+
|
|
207
|
+
command.to_s
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Client +opt=+ flags.
|
|
212
|
+
class ClientOptions
|
|
213
|
+
# The +opt=+ flags, in wire order.
|
|
214
|
+
FLAGS = %i[sqrlonly hardlock cps suk noiptest].freeze
|
|
215
|
+
|
|
216
|
+
# Request that the site disable all non-SQRL authentication.
|
|
217
|
+
attr_accessor :sqrlonly
|
|
218
|
+
# Request that the site disable all non-SQRL account recovery.
|
|
219
|
+
attr_accessor :hardlock
|
|
220
|
+
# The client can perform Client Provided Session redirection (+url=+ expected).
|
|
221
|
+
attr_accessor :cps
|
|
222
|
+
# Ask the server to return the account's SUK (needed before enable/remove).
|
|
223
|
+
attr_accessor :suk
|
|
224
|
+
# Do not require the request IP to match the nut's IP.
|
|
225
|
+
attr_accessor :noiptest
|
|
226
|
+
|
|
227
|
+
def initialize(sqrlonly: false, hardlock: false, cps: false, suk: false, noiptest: false)
|
|
228
|
+
@sqrlonly = sqrlonly
|
|
229
|
+
@hardlock = hardlock
|
|
230
|
+
@cps = cps
|
|
231
|
+
@suk = suk
|
|
232
|
+
@noiptest = noiptest
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
alias sqrlonly? sqrlonly
|
|
236
|
+
alias hardlock? hardlock
|
|
237
|
+
alias cps? cps
|
|
238
|
+
alias suk? suk
|
|
239
|
+
alias noiptest? noiptest
|
|
240
|
+
|
|
241
|
+
# Parse an +opt=+ value.
|
|
242
|
+
def self.from_wire(str)
|
|
243
|
+
o = new
|
|
244
|
+
str.to_s.split("~").each do |part|
|
|
245
|
+
flag = part.strip
|
|
246
|
+
o.public_send("#{flag}=", true) if FLAGS.include?(flag.to_sym)
|
|
247
|
+
end
|
|
248
|
+
o
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Render the +opt=+ value (+~+ separated), or nil when no option is set.
|
|
252
|
+
def to_wire
|
|
253
|
+
set = FLAGS.select { |f| public_send(f) }
|
|
254
|
+
set.empty? ? nil : set.join("~")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# @return [self.class] an unfrozen copy
|
|
258
|
+
def dup
|
|
259
|
+
ClientOptions.new(**to_h)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# @return [Hash] the values as a plain Hash
|
|
263
|
+
def to_h
|
|
264
|
+
FLAGS.to_h { |f| [f, public_send(f) ? true : false] }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# @return [Boolean] whether +other+ carries the same values
|
|
268
|
+
def ==(other)
|
|
269
|
+
other.is_a?(ClientOptions) && other.to_h == to_h
|
|
270
|
+
end
|
|
271
|
+
alias eql? ==
|
|
272
|
+
|
|
273
|
+
# @return [Integer] hash consistent with {#==}
|
|
274
|
+
def hash
|
|
275
|
+
to_h.hash
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# @return [String] a short description with secret material redacted
|
|
279
|
+
def inspect
|
|
280
|
+
"#<SQRL::ClientOptions #{to_wire || "(none)"}>"
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# One button of a server +ask=+.
|
|
285
|
+
AskButton = Struct.new(:label, :url, keyword_init: true) do
|
|
286
|
+
def initialize(label:, url: nil)
|
|
287
|
+
super
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# A server question (+ask=<msg>~<button1>[;url]~<button2>[;url]+, each part base64url).
|
|
292
|
+
class Ask
|
|
293
|
+
# The question text.
|
|
294
|
+
attr_reader :message
|
|
295
|
+
# Zero, one or two buttons. A client shows "OK" when there are none.
|
|
296
|
+
attr_reader :buttons
|
|
297
|
+
# Button numbers (1 or 2; 0 is the plain "OK" of a buttonless ask) whose answer lets
|
|
298
|
+
# the transaction proceed, or nil to accept any answer. A server-side setting only:
|
|
299
|
+
# it is not part of the wire form.
|
|
300
|
+
attr_reader :accept
|
|
301
|
+
|
|
302
|
+
def initialize(message:, buttons: [], accept: nil)
|
|
303
|
+
@message = message.to_s
|
|
304
|
+
@buttons = buttons.map { |b| b.is_a?(AskButton) ? b : AskButton.new(**b) }.freeze
|
|
305
|
+
@accept = accept.nil? ? nil : Array(accept).map(&:to_i).freeze
|
|
306
|
+
freeze
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Whether a +btn=+ answer lets the login proceed. Any answer does when +accept+ is nil.
|
|
310
|
+
def accepts?(btn)
|
|
311
|
+
return true if @accept.nil?
|
|
312
|
+
|
|
313
|
+
@accept.include?(btn.to_i)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Parse the wire form.
|
|
317
|
+
#
|
|
318
|
+
# @raise [FormatError]
|
|
319
|
+
def self.parse(value)
|
|
320
|
+
parts = value.to_s.split("~", -1)
|
|
321
|
+
message = Protocol.decode_text(parts.shift || "")
|
|
322
|
+
buttons = parts.first(2).map do |p|
|
|
323
|
+
label_b64, url = p.split(";", 2)
|
|
324
|
+
AskButton.new(label: Protocol.decode_text(label_b64 || ""), url: url)
|
|
325
|
+
end
|
|
326
|
+
new(message: message, buttons: buttons)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Render the wire form.
|
|
330
|
+
def to_wire
|
|
331
|
+
s = Util.b64u_encode(@message.b)
|
|
332
|
+
@buttons.each do |b|
|
|
333
|
+
s += "~#{Util.b64u_encode(b.label.to_s.b)}"
|
|
334
|
+
s += ";#{b.url}" if b.url
|
|
335
|
+
end
|
|
336
|
+
s
|
|
337
|
+
end
|
|
338
|
+
# @return [Boolean] whether +other+ carries the same values
|
|
339
|
+
|
|
340
|
+
def ==(other)
|
|
341
|
+
other.is_a?(Ask) && other.message == @message && other.buttons == @buttons && other.accept == @accept
|
|
342
|
+
end
|
|
343
|
+
alias eql? ==
|
|
344
|
+
# @return [Integer] hash consistent with {#==}
|
|
345
|
+
|
|
346
|
+
def hash
|
|
347
|
+
[@message, @buttons, @accept].hash
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# A parsed server response.
|
|
352
|
+
class ServerResponse
|
|
353
|
+
# The response exactly as received (whitespace removed): what the next request's
|
|
354
|
+
# +server=+ parameter carries.
|
|
355
|
+
attr_reader :raw
|
|
356
|
+
# All +name=value+ pairs in order.
|
|
357
|
+
attr_reader :params
|
|
358
|
+
attr_reader :ver, :nut, :tif, :qry, :url, :sin, :suk, :ask, :can
|
|
359
|
+
|
|
360
|
+
# Parse a base64url response body.
|
|
361
|
+
#
|
|
362
|
+
# @raise [ProtocolError, FormatError]
|
|
363
|
+
def self.parse(body)
|
|
364
|
+
new(body)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def initialize(body)
|
|
368
|
+
@raw = body.to_s.delete(" \t\r\n\f\v")
|
|
369
|
+
raise ProtocolError, "empty server response" if @raw.empty?
|
|
370
|
+
|
|
371
|
+
decoded = Util.b64u_decode(@raw).force_encoding(Encoding::UTF_8)
|
|
372
|
+
raise ProtocolError, "server response is not UTF-8" unless decoded.valid_encoding?
|
|
373
|
+
|
|
374
|
+
@params = Protocol.parse_pairs(decoded).freeze
|
|
375
|
+
@ver = fetch("ver") or raise ProtocolError, "response lacks ver"
|
|
376
|
+
unless Protocol.version_supported?(@ver, Protocol::VERSION)
|
|
377
|
+
raise ProtocolError, "server speaks version #{@ver}, not #{Protocol::VERSION}"
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
@nut = fetch("nut") or raise ProtocolError, "response lacks nut"
|
|
381
|
+
raise ProtocolError, "response has an empty nut" if @nut.empty?
|
|
382
|
+
|
|
383
|
+
@tif = Tif.parse(fetch("tif") || raise(ProtocolError, "response lacks tif"))
|
|
384
|
+
unless @tif.defined?
|
|
385
|
+
raise ProtocolError, "server returned undefined TIF bits (#{@tif.to_hex}); aborting"
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
@qry = fetch("qry") or raise ProtocolError, "response lacks qry"
|
|
389
|
+
suk = fetch("suk")
|
|
390
|
+
@suk = suk && !suk.empty? ? Util.b64u_decode_fixed(suk, 32) : nil
|
|
391
|
+
ask = fetch("ask")
|
|
392
|
+
@ask = ask && !ask.empty? ? Ask.parse(ask) : nil
|
|
393
|
+
@url = presence(fetch("url"))
|
|
394
|
+
@sin = presence(fetch("sin"))
|
|
395
|
+
@can = presence(fetch("can"))
|
|
396
|
+
freeze
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Value of an arbitrary parameter, or nil.
|
|
400
|
+
def [](name)
|
|
401
|
+
fetch(name.to_s)
|
|
402
|
+
# @return [Boolean] whether +other+ carries the same values
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# @return [Boolean] whether +other+ carries the same values
|
|
406
|
+
def ==(other)
|
|
407
|
+
other.is_a?(ServerResponse) && other.raw == @raw
|
|
408
|
+
end
|
|
409
|
+
# @return [Integer] hash consistent with {#==}
|
|
410
|
+
alias eql? ==
|
|
411
|
+
|
|
412
|
+
# @return [Integer] hash consistent with {#==}
|
|
413
|
+
def hash
|
|
414
|
+
@raw.hash
|
|
415
|
+
end
|
|
416
|
+
# @return [String] a short description with secret material redacted
|
|
417
|
+
|
|
418
|
+
def inspect
|
|
419
|
+
"#<SQRL::ServerResponse tif=0x#{@tif.to_hex} nut=#{@nut} qry=#{@qry}>"
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
private
|
|
423
|
+
|
|
424
|
+
def fetch(name)
|
|
425
|
+
pair = @params.find { |(k, _)| k == name }
|
|
426
|
+
pair && pair[1]
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def presence(value)
|
|
430
|
+
value.nil? || value.empty? ? nil : value
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Server
|
|
5
|
+
# Configuration of a {Handler}.
|
|
6
|
+
#
|
|
7
|
+
# config = SQRL::Server::Config.new(host: "example.com") do |c|
|
|
8
|
+
# c.on_unknown_identity = :link
|
|
9
|
+
# c.ask = SQRL::Ask.new(message: "Log in to Example?", buttons: [{ label: "Yes" }, { label: "No" }])
|
|
10
|
+
# end
|
|
11
|
+
class Config
|
|
12
|
+
# Accepted values of +on_unknown_identity+.
|
|
13
|
+
UNKNOWN_IDENTITY_POLICIES = %i[create link refuse].freeze
|
|
14
|
+
# URL schemes a server can issue: +sqrl+ (HTTPS) or, in testing mode only, +qrl+.
|
|
15
|
+
SCHEMES = %w[sqrl qrl].freeze
|
|
16
|
+
|
|
17
|
+
# Host (with optional port) placed in issued +sqrl://+ URLs. Must be the public host.
|
|
18
|
+
attr_accessor :host
|
|
19
|
+
# Path of the SQRL endpoint, also used in +qry=+.
|
|
20
|
+
attr_accessor :path
|
|
21
|
+
# Path of the CPS callback used by the default +cps_url+ builder.
|
|
22
|
+
attr_accessor :callback_path
|
|
23
|
+
# The URL scheme issued: always +sqrl+ (which means HTTPS) unless +insecure_http+ is
|
|
24
|
+
# set, in which case it is +qrl+. Read-only; set +insecure_http+ instead.
|
|
25
|
+
attr_reader :scheme
|
|
26
|
+
# Loopback-only testing mode: issue +qrl://+ (plain http) URLs. +qrl://+ is not part
|
|
27
|
+
# of the final SQRL specification and real SQRL clients do not accept it; it exists
|
|
28
|
+
# so a site can be exercised against the browser extension's test server without
|
|
29
|
+
# TLS. Validation refuses it for any host that is not loopback.
|
|
30
|
+
attr_accessor :insecure_http
|
|
31
|
+
# +x=N+ path extension to scope identities to a path prefix (nil for none).
|
|
32
|
+
attr_accessor :path_extension
|
|
33
|
+
# Lifetime in seconds of nuts (login page and per-response).
|
|
34
|
+
attr_accessor :nut_ttl
|
|
35
|
+
# Lifetime in seconds of the one-time CPS landing token.
|
|
36
|
+
attr_accessor :cps_token_ttl
|
|
37
|
+
# Enforce +IPS_MATCHED+ unless the client sends +noiptest+.
|
|
38
|
+
attr_accessor :require_ip_match
|
|
39
|
+
# {Ask} to pose in query replies, or a callable +(context) -> Ask or nil+.
|
|
40
|
+
attr_accessor :ask
|
|
41
|
+
# Secret index challenge (+sin=+): a String, or a callable +(context) -> String or nil+.
|
|
42
|
+
attr_accessor :sin
|
|
43
|
+
# +:create+ (auto account), +:link+ (needs a pending login with +link_user_ref+) or
|
|
44
|
+
# +:refuse+.
|
|
45
|
+
attr_accessor :on_unknown_identity
|
|
46
|
+
# Callable +(identity, pending_login, command)+ invoked after a successful +ident+ or
|
|
47
|
+
# +enable+.
|
|
48
|
+
attr_accessor :on_authenticated
|
|
49
|
+
# Callable +(token, pending_login) -> String+ building the CPS +url=+.
|
|
50
|
+
attr_accessor :cps_url
|
|
51
|
+
# Callable returning the current Time (injectable for tests).
|
|
52
|
+
attr_accessor :clock
|
|
53
|
+
# Logger (anything responding to +debug+/+info+/+warn+), or nil.
|
|
54
|
+
attr_accessor :logger
|
|
55
|
+
# Hosts (Strings, compared case-insensitively with or without port, or Regexps)
|
|
56
|
+
# that an integration deriving +host+ from the request may use; nil allows any.
|
|
57
|
+
attr_accessor :allowed_hosts
|
|
58
|
+
# Run {Handler#prune!} once every this many issued or handled requests; nil disables.
|
|
59
|
+
attr_accessor :prune_every
|
|
60
|
+
# Callable +(context)+ run before a login URL is issued; context has +:ip+ and
|
|
61
|
+
# +:pending_login_id+. Raise {RateLimited} to refuse.
|
|
62
|
+
attr_accessor :before_issue
|
|
63
|
+
# Callable +(context)+ run before a POST is processed (+phase: :request+, with
|
|
64
|
+
# +:ip+ and +:nut+) and again once the command is known (+phase: :command+, adding
|
|
65
|
+
# +:command+ and +:idk+). Raise {RateLimited} to refuse.
|
|
66
|
+
attr_accessor :before_handle
|
|
67
|
+
|
|
68
|
+
def initialize(host: nil, path: "/sqrl/cli", callback_path: "/sqrl/callback", scheme: nil, insecure_http: false,
|
|
69
|
+
path_extension: nil, nut_ttl: 300, cps_token_ttl: 60, require_ip_match: true,
|
|
70
|
+
ask: nil, sin: nil, on_unknown_identity: :create, on_authenticated: nil,
|
|
71
|
+
cps_url: nil, clock: nil, logger: nil, allowed_hosts: nil, prune_every: 1000,
|
|
72
|
+
before_issue: nil, before_handle: nil)
|
|
73
|
+
@allowed_hosts = allowed_hosts
|
|
74
|
+
@prune_every = prune_every
|
|
75
|
+
@before_issue = before_issue
|
|
76
|
+
@before_handle = before_handle
|
|
77
|
+
@host = host
|
|
78
|
+
@path = path
|
|
79
|
+
@callback_path = callback_path
|
|
80
|
+
@insecure_http = insecure_http ? true : false
|
|
81
|
+
@insecure_http = true if scheme.to_s == "qrl"
|
|
82
|
+
@scheme = @insecure_http ? "qrl" : (scheme || "sqrl").to_s
|
|
83
|
+
@path_extension = path_extension
|
|
84
|
+
@nut_ttl = nut_ttl
|
|
85
|
+
@cps_token_ttl = cps_token_ttl
|
|
86
|
+
@require_ip_match = require_ip_match
|
|
87
|
+
@ask = ask
|
|
88
|
+
@sin = sin
|
|
89
|
+
@on_unknown_identity = on_unknown_identity
|
|
90
|
+
@on_authenticated = on_authenticated
|
|
91
|
+
@cps_url = cps_url
|
|
92
|
+
@clock = clock || -> { Time.now }
|
|
93
|
+
@logger = logger
|
|
94
|
+
yield self if block_given?
|
|
95
|
+
validate!
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Raise ArgumentError when a value is unusable.
|
|
99
|
+
def validate!
|
|
100
|
+
raise ArgumentError, "host is required" if @host.nil? || @host.to_s.strip.empty?
|
|
101
|
+
raise ArgumentError, "host must not contain a scheme or path" if @host.match?(%r{://|/})
|
|
102
|
+
raise ArgumentError, "path must start with /" unless @path.to_s.start_with?("/")
|
|
103
|
+
raise ArgumentError, "callback_path must start with /" unless @callback_path.to_s.start_with?("/")
|
|
104
|
+
raise ArgumentError, "scheme must be sqrl or qrl" unless SCHEMES.include?(@scheme)
|
|
105
|
+
if insecure_http? && !URL.loopback?(bare_host)
|
|
106
|
+
raise ArgumentError, "insecure_http (plain http, qrl://) is only allowed for loopback hosts, not #{bare_host}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
warn_insecure_once if insecure_http?
|
|
110
|
+
unless @path_extension.nil? || (@path_extension.is_a?(Integer) && @path_extension.positive?)
|
|
111
|
+
raise ArgumentError, "path_extension must be nil or a positive Integer"
|
|
112
|
+
end
|
|
113
|
+
raise ArgumentError, "nut_ttl must be a positive number" unless positive_number?(@nut_ttl)
|
|
114
|
+
raise ArgumentError, "cps_token_ttl must be a positive number" unless positive_number?(@cps_token_ttl)
|
|
115
|
+
unless UNKNOWN_IDENTITY_POLICIES.include?(@on_unknown_identity)
|
|
116
|
+
raise ArgumentError, "on_unknown_identity must be one of #{UNKNOWN_IDENTITY_POLICIES.join(", ")}"
|
|
117
|
+
end
|
|
118
|
+
raise ArgumentError, "ask must be an SQRL::Ask or a callable" unless valid_ask?
|
|
119
|
+
raise ArgumentError, "sin must be a String or a callable" unless valid_sin?
|
|
120
|
+
unless @on_authenticated.nil? || callable?(@on_authenticated)
|
|
121
|
+
raise ArgumentError,
|
|
122
|
+
"on_authenticated must be callable"
|
|
123
|
+
end
|
|
124
|
+
raise ArgumentError, "cps_url must be callable" unless @cps_url.nil? || callable?(@cps_url)
|
|
125
|
+
raise ArgumentError, "clock must be callable" unless callable?(@clock)
|
|
126
|
+
unless @allowed_hosts.nil? || (@allowed_hosts.is_a?(Array) && @allowed_hosts.all? do |h|
|
|
127
|
+
h.is_a?(String) || h.is_a?(Regexp)
|
|
128
|
+
end)
|
|
129
|
+
raise ArgumentError, "allowed_hosts must be nil or an Array of Strings and Regexps"
|
|
130
|
+
end
|
|
131
|
+
unless @prune_every.nil? || (@prune_every.is_a?(Integer) && @prune_every.positive?)
|
|
132
|
+
raise ArgumentError, "prune_every must be nil or a positive Integer"
|
|
133
|
+
end
|
|
134
|
+
raise ArgumentError, "before_issue must be callable" unless @before_issue.nil? || callable?(@before_issue)
|
|
135
|
+
raise ArgumentError, "before_handle must be callable" unless @before_handle.nil? || callable?(@before_handle)
|
|
136
|
+
|
|
137
|
+
true
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The host without port (IPv6 literals keep their brackets), lower-cased.
|
|
141
|
+
def bare_host
|
|
142
|
+
host, = URL.split_host_port(@host.to_s)
|
|
143
|
+
host.to_s.downcase
|
|
144
|
+
rescue FormatError
|
|
145
|
+
@host.to_s.downcase
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Whether +host+ (with or without port) is acceptable under +allowed_hosts+. Always
|
|
149
|
+
# true when no list is configured.
|
|
150
|
+
def host_allowed?(host)
|
|
151
|
+
return true if @allowed_hosts.nil?
|
|
152
|
+
|
|
153
|
+
candidate = host.to_s.downcase
|
|
154
|
+
bare = begin
|
|
155
|
+
URL.split_host_port(candidate).first.to_s
|
|
156
|
+
rescue FormatError
|
|
157
|
+
candidate
|
|
158
|
+
end
|
|
159
|
+
@allowed_hosts.any? do |allowed|
|
|
160
|
+
if allowed.is_a?(Regexp)
|
|
161
|
+
candidate.match?(allowed) || bare.match?(allowed)
|
|
162
|
+
else
|
|
163
|
+
a = allowed.downcase
|
|
164
|
+
a == candidate || a == bare
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# +https+ (or +http+ in +insecure_http+ mode).
|
|
170
|
+
def http_scheme
|
|
171
|
+
insecure_http? ? "http" : "https"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Whether the site issues +qrl://+ (plain http) URLs; see +insecure_http+.
|
|
175
|
+
def insecure_http? = @insecure_http ? true : false
|
|
176
|
+
|
|
177
|
+
# Warning logged once when +insecure_http+ is enabled.
|
|
178
|
+
INSECURE_WARNING = "[sqrl] insecure_http is a loopback-only testing mode; real SQRL clients require " \
|
|
179
|
+
"sqrl:// over HTTPS"
|
|
180
|
+
@insecure_warned = false
|
|
181
|
+
@insecure_warning_mutex = Mutex.new
|
|
182
|
+
|
|
183
|
+
class << self
|
|
184
|
+
# Reset the one-time insecure_http warning (tests).
|
|
185
|
+
def reset_warnings!
|
|
186
|
+
@insecure_warning_mutex.synchronize { @insecure_warned = false }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Log {INSECURE_WARNING} once per process.
|
|
190
|
+
# @api private
|
|
191
|
+
def warn_insecure_once(logger)
|
|
192
|
+
@insecure_warning_mutex.synchronize do
|
|
193
|
+
return if @insecure_warned
|
|
194
|
+
|
|
195
|
+
@insecure_warned = true
|
|
196
|
+
end
|
|
197
|
+
logger.respond_to?(:warn) ? logger.warn(INSECURE_WARNING) : Kernel.warn(INSECURE_WARNING)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# +https://host+
|
|
202
|
+
def origin
|
|
203
|
+
"#{http_scheme}://#{@host}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @return [Time] the configured clock's current time
|
|
207
|
+
def now
|
|
208
|
+
@clock.call
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Resolve the +ask+ setting for a request context.
|
|
212
|
+
def ask_for(context)
|
|
213
|
+
callable?(@ask) ? @ask.call(context) : @ask
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Resolve the +sin+ setting for a request context.
|
|
217
|
+
def sin_for(context)
|
|
218
|
+
value = callable?(@sin) ? @sin.call(context) : @sin
|
|
219
|
+
value&.to_s
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Build the CPS +url=+ for a token.
|
|
223
|
+
def cps_url_for(token, pending_login)
|
|
224
|
+
return @cps_url.call(token, pending_login) if @cps_url
|
|
225
|
+
|
|
226
|
+
"#{origin}#{@callback_path}?t=#{token}"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def require_ip_match? = @require_ip_match ? true : false
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
# @return [void] see {Config.warn_insecure_once}
|
|
234
|
+
def warn_insecure_once
|
|
235
|
+
self.class.warn_insecure_once(@logger)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def positive_number?(v)
|
|
239
|
+
v.is_a?(Numeric) && v.positive?
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def callable?(v)
|
|
243
|
+
v.respond_to?(:call)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def valid_ask?
|
|
247
|
+
@ask.nil? || @ask.is_a?(Ask) || callable?(@ask)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def valid_sin?
|
|
251
|
+
@sin.nil? || @sin.is_a?(String) || callable?(@sin)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|