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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1146c068eb45ee56e853601aab7d2cc529814e6667254579a9133d3a44977a63
4
- data.tar.gz: 87ec42fb26124ec1d0d23fc6d11f74d4dc434c22f17dafc0ef803053555cba22
3
+ metadata.gz: 4349344a78214a7b364887b59f9668ff67a7e6aa03961dfe6e3400ad3f75c454
4
+ data.tar.gz: 077a363c8000cfae1e4e2214bedd7e067515458184fc536ea4fc6854969ffd95
5
5
  SHA512:
6
- metadata.gz: 4fd9a937c4b7c134ca7c85900dd46184c9275bca550744b942864beacb4c486a704c99e18fb05e67096dca828aa205be1c8d52af077c112a5b128e80eb79aa28
7
- data.tar.gz: 5a7477a6d9a87ab52987087c7afb009a21037928b446171a6e8353837be995f6a8c318020c7c48234ddbdd579e8bec6f42b960a65cebc3396ad238105d1073fa
6
+ metadata.gz: 5764d722e2431303a71c1c3c82e07b9944452be5730e56ef2d87c12181afdd3fafee126ffde881d80fb845c2b1033ba4bc064cbc996c5fd4ff24eb7ac2913da0
7
+ data.tar.gz: 8ed7f323f1c4ac4d859bf65658deb79c70e961a0796a637169c91b90a6d4ebcbd1368b9305b8a3fb43ecec210310fd84dafd23ff81ca65a0af5fd3bbc09cd4ce
data/CHANGELOG.md CHANGED
@@ -1,16 +1,65 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1 — 2026-10-20
3
+ All notable changes to `sqrl-core` are documented in this file.
4
4
 
5
- - Fixed: Ed25519 and X25519 keys are built from their DER encodings when the openssl gem
6
- has no `OpenSSL::PKey.new_raw_private_key` / `new_raw_public_key` (openssl 3.0 and 3.1,
7
- bundled with Ruby 3.1 and 3.2). 0.1.0 raised `NoMethodError` on those Rubies as soon as
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
- ## 0.1.0 — 2026-10-20
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
@@ -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
- return fail_with(Tif::IDENTITY_SUPERSEDED | Tif::COMMAND_FAILED) if @stores.identities.superseded?(idk)
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
- @extra << ["suk", Util.b64u_encode(suk_to_return)] if suk_to_return
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, hand out a fresh nut bound to a new
445
- # anonymous pending login so the client can retry.
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 = @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
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
- @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)
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
- trimmed = Storage.trim_ascii(input)
411
- raise FormatError, "identity data too short" if trimmed.bytesize < 8
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 = trimmed.byteslice(0, 8)
416
+ header = data.byteslice(0, 8)
414
417
  if header == HEADER_TEXT
415
- text = trimmed.byteslice(8..)
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(trimmed.byteslice(8..)) if header == HEADER_BINARY
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SQRL
4
4
  # Version shared by every gem in the family.
5
- VERSION = "0.1.1"
5
+ VERSION = "0.1.2"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sqrl-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel P. Clark