sqrl-core 0.1.1 → 0.1.2
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 +4 -4
- data/CHANGELOG.md +60 -6
- data/lib/sqrl/server/stores.rb +6 -0
- data/lib/sqrl/server.rb +45 -19
- data/lib/sqrl/storage.rb +8 -5
- data/lib/sqrl/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4349344a78214a7b364887b59f9668ff67a7e6aa03961dfe6e3400ad3f75c454
|
|
4
|
+
data.tar.gz: 077a363c8000cfae1e4e2214bedd7e067515458184fc536ea4fc6854969ffd95
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5764d722e2431303a71c1c3c82e07b9944452be5730e56ef2d87c12181afdd3fafee126ffde881d80fb845c2b1033ba4bc064cbc996c5fd4ff24eb7ac2913da0
|
|
7
|
+
data.tar.gz: 8ed7f323f1c4ac4d859bf65658deb79c70e961a0796a637169c91b90a6d4ebcbd1368b9305b8a3fb43ecec210310fd84dafd23ff81ca65a0af5fd3bbc09cd4ce
|
data/CHANGELOG.md
CHANGED
|
@@ -1,16 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
All notable changes to `sqrl-core` are documented in this file.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
an identity was derived, signed or verified.
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
|
|
6
|
+
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Every gem in
|
|
7
|
+
the family shares one version number and is released in lockstep.
|
|
9
8
|
|
|
10
|
-
##
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [0.1.2] - 2026-10-21
|
|
12
|
+
|
|
13
|
+
The server side now follows Steve Gibson's final specification (*SQRL On The Wire* v1.07,
|
|
14
|
+
December 2019) and his latest rulings in the `grc.sqrl` newsgroup (cited as
|
|
15
|
+
`grc.sqrl:<article>`); see `docs/protocol.md`.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- The server returns `suk=` in the reply to every command when the client sends `opt=suk`,
|
|
20
|
+
and always for a disabled account or a previous-identity match. It used to be returned on
|
|
21
|
+
`query` only (a MUST: grc.sqrl:12289).
|
|
22
|
+
- A successful re-key replies with 0x01 set and 0x02 clear (the post-update state) and
|
|
23
|
+
clears a disabled state along with the key update (grc.sqrl:20273; On The Wire p. 17).
|
|
24
|
+
- `disable` in CPS mode returns `url=` like every other non-query command.
|
|
25
|
+
- `sqrlonly` / `hardlock` are copied from every successful non-query command (`ident`,
|
|
26
|
+
`enable`, `disable`), not from `ident` alone.
|
|
27
|
+
- A superseded identity is refused with exactly `0x240`.
|
|
28
|
+
- An unknown, expired or reused nut draws a transient error whose fresh nut is not
|
|
29
|
+
registered, so the retry fails identically, which is the client's cue to have the user
|
|
30
|
+
refresh the page (grc.sqrl:23543). Previously the retry "succeeded" against an anonymous
|
|
31
|
+
pending login that no browser could ever complete.
|
|
32
|
+
- `PendingLogin#cps?` is new.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Binary `sqrldata` files whose final byte happened to be an ASCII whitespace character
|
|
37
|
+
(one in about forty, since the last block ends with a random AES-GCM tag) lost that byte on
|
|
38
|
+
parse and failed with "block ... claims N bytes but only N-1 remain". Only leading
|
|
39
|
+
whitespace is skipped before the header now; the `SQRLDATA` text form is still trimmed.
|
|
40
|
+
|
|
41
|
+
### Security
|
|
42
|
+
|
|
43
|
+
- `Handler#status` reports `authenticated: false` for a login the client completed in CPS
|
|
44
|
+
mode, so a polling page can no longer be signed in on the strength of a same-device
|
|
45
|
+
login: the specification requires that only the browser holding the one-time `url=`
|
|
46
|
+
token is signed in.
|
|
47
|
+
|
|
48
|
+
## [0.1.1] - 2026-10-20
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- Ed25519 and X25519 keys are built from their DER encodings when the openssl gem has no
|
|
53
|
+
`OpenSSL::PKey.new_raw_private_key` / `new_raw_public_key` (openssl 3.0 and 3.1, bundled
|
|
54
|
+
with Ruby 3.1 and 3.2). 0.1.0 raised `NoMethodError` on those Rubies as soon as an
|
|
55
|
+
identity was derived, signed or verified.
|
|
56
|
+
|
|
57
|
+
## [0.1.0] - 2026-10-20
|
|
11
58
|
|
|
12
59
|
First release.
|
|
13
60
|
|
|
61
|
+
### Added
|
|
62
|
+
|
|
14
63
|
- Complete SQRL wire protocol: `query`, `ident`, `disable`, `enable`, `remove`, every TIF
|
|
15
64
|
bit, `ask`/`btn` with a server-side accept-list, `sin`/`ins`/`pins`, previous identities
|
|
16
65
|
and re-keying, `x=` path extension, Alt-IDs, `can=`, Client Provided Session.
|
|
@@ -26,3 +75,8 @@ First release.
|
|
|
26
75
|
`before_handle` hooks and `SQRL::Server::RateLimited`.
|
|
27
76
|
- A shared store-contract spec for store implementations.
|
|
28
77
|
- Requires Ruby 3.1 or newer.
|
|
78
|
+
|
|
79
|
+
[Unreleased]: https://github.com/danielpclark/sqrl-ruby/compare/v0.1.2...HEAD
|
|
80
|
+
[0.1.2]: https://github.com/danielpclark/sqrl-ruby/compare/v0.1.1...v0.1.2
|
|
81
|
+
[0.1.1]: https://github.com/danielpclark/sqrl-ruby/compare/v0.1.0...v0.1.1
|
|
82
|
+
[0.1.0]: https://github.com/danielpclark/sqrl-ruby/releases/tag/v0.1.0
|
data/lib/sqrl/server/stores.rb
CHANGED
|
@@ -62,6 +62,12 @@ module SQRL
|
|
|
62
62
|
def authenticated? = !authenticated_idk.nil?
|
|
63
63
|
def asked? = asked ? true : false
|
|
64
64
|
|
|
65
|
+
# Whether the SQRL client completed this login in CPS mode (a one-time +url=+ token was
|
|
66
|
+
# issued). Such a login may only be completed by the browser that presents that token,
|
|
67
|
+
# never through the poll. The token field stays set until the token is claimed, so an
|
|
68
|
+
# unclaimed or expired token keeps the poll closed, as the specification requires.
|
|
69
|
+
def cps? = !cps_token.nil?
|
|
70
|
+
|
|
65
71
|
def expired?(now)
|
|
66
72
|
!expires_at.nil? && expires_at <= now
|
|
67
73
|
end
|
data/lib/sqrl/server.rb
CHANGED
|
@@ -95,6 +95,10 @@ module SQRL
|
|
|
95
95
|
def status(pending_login_id)
|
|
96
96
|
login = pending_login_id && @stores.pending_logins.find(pending_login_id)
|
|
97
97
|
return { authenticated: false, idk: nil } if login.nil? || login.expired?(@config.now)
|
|
98
|
+
# A login the client flagged cps is completed only through the url= token the client
|
|
99
|
+
# hands its own browser: "the web server ... MUST NOT authenticate the browser session
|
|
100
|
+
# upon successful authentication" (On The Wire v1.07 p.14; Operating Details p.4).
|
|
101
|
+
return { authenticated: false, idk: nil } if login.cps?
|
|
98
102
|
|
|
99
103
|
{ authenticated: login.authenticated?,
|
|
100
104
|
idk: login.authenticated_idk && Util.b64u_encode(login.authenticated_idk) }
|
|
@@ -256,7 +260,11 @@ module SQRL
|
|
|
256
260
|
return fail_with(Tif::COMMAND_FAILED)
|
|
257
261
|
end
|
|
258
262
|
|
|
259
|
-
|
|
263
|
+
if @stores.identities.superseded?(idk)
|
|
264
|
+
# "the server must immediately fail that client query by returning a TIF of 0x240"
|
|
265
|
+
@tif = 0
|
|
266
|
+
return fail_with(Tif::IDENTITY_SUPERSEDED | Tif::COMMAND_FAILED)
|
|
267
|
+
end
|
|
260
268
|
|
|
261
269
|
# --- which identity does the server know? ---
|
|
262
270
|
current = @stores.identities.find(idk)
|
|
@@ -291,22 +299,24 @@ module SQRL
|
|
|
291
299
|
ins_ok = @sin.nil? || previous.ins.nil? || Util.secure_compare(previous.ins, cget.call("pins").to_s)
|
|
292
300
|
if suk_new && vuk_new && urs_ok.call(previous.vuk) && ins_ok
|
|
293
301
|
current = @stores.identities.rekey(pidk, idk, suk: suk_new, vuk: vuk_new, ins: cget.call("ins"))
|
|
302
|
+
# The urs under the previous identity's VUK carries the same (rescue code) authority
|
|
303
|
+
# as an enable, so a disabled account comes back enabled under its new key, "so that
|
|
304
|
+
# the account can be immediately used under the new key" (Gibson, grc.sqrl:20273).
|
|
305
|
+
current = @stores.identities.update(idk, disabled: false) if current.disabled?
|
|
294
306
|
@result.rekeyed_from = pidk
|
|
295
307
|
@result.identity = current
|
|
296
|
-
matched = current
|
|
297
308
|
id_match = true
|
|
309
|
+
# TIF bits describe the account AFTER the update: current-ID match on, previous-ID
|
|
310
|
+
# match off (On The Wire v1.07 p.16-17; Gibson, grc.sqrl:22934).
|
|
298
311
|
@tif |= Tif::ID_MATCH
|
|
312
|
+
@tif &= ~(Tif::PREVIOUS_ID_MATCH | Tif::SQRL_DISABLED)
|
|
299
313
|
else
|
|
300
314
|
rekey_failed = true
|
|
301
315
|
end
|
|
302
316
|
end
|
|
303
317
|
|
|
304
|
-
suk_to_return = nil
|
|
305
318
|
case cmd
|
|
306
319
|
when Command::QUERY
|
|
307
|
-
if matched && (opts.suk? || matched.disabled? || prev_match)
|
|
308
|
-
suk_to_return = matched.suk
|
|
309
|
-
end
|
|
310
320
|
# An ask is only valid in a query reply that recognises the current identity.
|
|
311
321
|
if ask_pending && id_match
|
|
312
322
|
@extra << ["ask", ask.to_wire]
|
|
@@ -340,16 +350,21 @@ module SQRL
|
|
|
340
350
|
if rekey_failed || !id_match
|
|
341
351
|
@tif |= Tif::COMMAND_FAILED
|
|
342
352
|
else
|
|
343
|
-
current = @stores.identities.update(idk, disabled: true
|
|
353
|
+
current = @stores.identities.update(idk, disabled: true, sqrlonly: opts.sqrlonly?,
|
|
354
|
+
hardlock: opts.hardlock?)
|
|
344
355
|
@result.identity = current
|
|
345
356
|
@tif |= Tif::SQRL_DISABLED
|
|
357
|
+
# "url= must be provided by the server in its response to any command, other than
|
|
358
|
+
# query, when the SQRL client includes opt=cps" (On The Wire v1.07 p.14).
|
|
359
|
+
issue_cps_url if opts.cps?
|
|
346
360
|
end
|
|
347
361
|
when Command::ENABLE
|
|
348
362
|
if rekey_failed || !id_match || ask_refused
|
|
349
363
|
@tif |= Tif::COMMAND_FAILED
|
|
350
364
|
elsif prev_match || urs_ok.call(current.vuk)
|
|
351
365
|
# 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
|
|
366
|
+
current = @stores.identities.update(idk, disabled: false, logins: current.logins.to_i + 1,
|
|
367
|
+
sqrlonly: opts.sqrlonly?, hardlock: opts.hardlock?)
|
|
353
368
|
@tif &= ~Tif::SQRL_DISABLED
|
|
354
369
|
authenticate!(current, cmd, opts)
|
|
355
370
|
else
|
|
@@ -369,7 +384,14 @@ module SQRL
|
|
|
369
384
|
end
|
|
370
385
|
end
|
|
371
386
|
|
|
372
|
-
|
|
387
|
+
# The stored SUK goes back whenever the client asked for it with opt=suk (a MUST:
|
|
388
|
+
# Gibson, grc.sqrl:12289, 9 Jan 2016) and whenever the client is likely to need it for
|
|
389
|
+
# a urs: the account is disabled, or it was recognised by a previous identity
|
|
390
|
+
# (On The Wire v1.07 p.14). It accompanies every command's reply, as GRC's server does.
|
|
391
|
+
identity_now = @result.identity
|
|
392
|
+
if identity_now && (opts.suk? || identity_now.disabled? || prev_match)
|
|
393
|
+
@extra << ["suk", Util.b64u_encode(identity_now.suk)]
|
|
394
|
+
end
|
|
373
395
|
respond
|
|
374
396
|
end
|
|
375
397
|
|
|
@@ -441,17 +463,19 @@ module SQRL
|
|
|
441
463
|
@extra << ["url", url]
|
|
442
464
|
end
|
|
443
465
|
|
|
444
|
-
# Unknown or reused nut: transient error
|
|
445
|
-
#
|
|
466
|
+
# Unknown, expired or reused nut: transient error. This server keeps its state "by
|
|
467
|
+
# reference" (On The Wire, "Transient Error Details", second case), so the browser
|
|
468
|
+
# session behind a stale nut cannot be recovered. The reply carries a fresh nut, as every
|
|
469
|
+
# reply must, but that nut is deliberately not registered: the client's retry fails with
|
|
470
|
+
# the identical TIF, which is the client's cue to tell its user to refresh the login page
|
|
471
|
+
# (Gibson, grc.sqrl:23543, 23 Nov 2019; On The Wire v1.07 p.17).
|
|
446
472
|
def transient_error
|
|
447
473
|
@tif |= Tif::TRANSIENT_ERROR | Tif::COMMAND_FAILED
|
|
448
|
-
@login_id =
|
|
449
|
-
@login =
|
|
450
|
-
expires_at: @now + @config.nut_ttl, created_at: @now)
|
|
451
|
-
@result.pending_login_id = @login_id
|
|
474
|
+
@login_id = nil
|
|
475
|
+
@login = nil
|
|
452
476
|
@bound = nil
|
|
453
477
|
@sin = @config.sin_for(context)
|
|
454
|
-
respond
|
|
478
|
+
respond(register_nut: false)
|
|
455
479
|
end
|
|
456
480
|
|
|
457
481
|
def fail_with(bits)
|
|
@@ -459,14 +483,16 @@ module SQRL
|
|
|
459
483
|
respond
|
|
460
484
|
end
|
|
461
485
|
|
|
462
|
-
def respond
|
|
486
|
+
def respond(register_nut: true)
|
|
463
487
|
nut = @handler.new_nut
|
|
464
488
|
text = "ver=#{Protocol::VERSION}\r\nnut=#{nut}\r\ntif=#{Tif.new(@tif).to_hex}\r\nqry=#{@config.path}?nut=#{nut}\r\n"
|
|
465
489
|
text << "sin=#{@sin}\r\n" if @sin
|
|
466
490
|
@extra.each { |k, v| text << "#{k}=#{v}\r\n" }
|
|
467
491
|
body = Util.b64u_encode(text.b)
|
|
468
|
-
|
|
469
|
-
|
|
492
|
+
if register_nut
|
|
493
|
+
@stores.nuts.issue(nut: nut, pending_login_id: @login_id, expected_server: body,
|
|
494
|
+
ip: @record ? @record.ip : @ip, bound_idk: @bound, expires_at: @now + @config.nut_ttl)
|
|
495
|
+
end
|
|
470
496
|
@result.body = body
|
|
471
497
|
@result.tif = Tif.new(@tif)
|
|
472
498
|
@result.pending_login = @login
|
data/lib/sqrl/storage.rb
CHANGED
|
@@ -407,17 +407,20 @@ module SQRL
|
|
|
407
407
|
|
|
408
408
|
# Parse either the binary (+sqrldata+) or the textual base64url (+SQRLDATA+) form.
|
|
409
409
|
def self.parse(input)
|
|
410
|
-
|
|
411
|
-
|
|
410
|
+
# Only leading whitespace is skipped before the header is read: the binary form ends
|
|
411
|
+
# with an AES-GCM tag whose last byte may itself be a whitespace character, so trimming
|
|
412
|
+
# the tail would truncate roughly one file in forty.
|
|
413
|
+
data = input.b.sub(/\A[ \t\r\n\f\v]+/, "")
|
|
414
|
+
raise FormatError, "identity data too short" if data.bytesize < 8
|
|
412
415
|
|
|
413
|
-
header =
|
|
416
|
+
header = data.byteslice(0, 8)
|
|
414
417
|
if header == HEADER_TEXT
|
|
415
|
-
text =
|
|
418
|
+
text = Storage.trim_ascii(data.byteslice(8..))
|
|
416
419
|
raise FormatError, "invalid SQRLDATA text" unless text.force_encoding("UTF-8").valid_encoding?
|
|
417
420
|
|
|
418
421
|
return parse_blocks(Util.b64u_decode(text))
|
|
419
422
|
end
|
|
420
|
-
return parse_blocks(
|
|
423
|
+
return parse_blocks(data.byteslice(8..)) if header == HEADER_BINARY
|
|
421
424
|
|
|
422
425
|
raise FormatError, "missing sqrldata header"
|
|
423
426
|
end
|
data/lib/sqrl/version.rb
CHANGED