sqrl-client 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 +17 -0
- data/LICENSE +21 -0
- data/README.md +154 -0
- data/exe/sqrl-client +7 -0
- data/lib/sqrl/client/agent.rb +399 -0
- data/lib/sqrl/client/capybara.rb +52 -0
- data/lib/sqrl/client/cli.rb +409 -0
- data/lib/sqrl/client/conformance/report.rb +100 -0
- data/lib/sqrl/client/conformance/rspec.rb +30 -0
- data/lib/sqrl/client/conformance/runner.rb +475 -0
- data/lib/sqrl/client/conformance/site.rb +56 -0
- data/lib/sqrl/client/conformance.rb +23 -0
- data/lib/sqrl/client/discovery.rb +131 -0
- data/lib/sqrl/client/errors.rb +52 -0
- data/lib/sqrl/client/identity.rb +229 -0
- data/lib/sqrl/client/transport.rb +189 -0
- data/lib/sqrl/client.rb +28 -0
- metadata +94 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0fcfd84f13a0b97bdd7742c8ff8d7cc918cada5c3ce355f51233c77e69463aa6
|
|
4
|
+
data.tar.gz: 0eef85f22ed8e989649837454f1ceb28994dfd3d3f71ac10b54dac0631ddbd93
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 896a8745c0a4fdcb39babdb3ad61776b377eac8d60a45a50eb5edbababc62e3e8139f83667edb7ab9cdcd67903c1fa26427dc24f0424b5bd9f2c1ddc05a06442
|
|
7
|
+
data.tar.gz: 6651d2647ce80e827fe772770e69eae96bf46c1a7e9e4d1088e7c113759db729e9cafc0de2d7eac1c335a4c6cedf1843bc16fe9f4afc634f8605fa5e38e0ce29
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-20
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- `SQRL::Client::Agent`: login, query, disable, enable, remove against any SQRL site, with
|
|
8
|
+
login-page discovery, previous-identity handling, `ask` and `sin` answering, transient
|
|
9
|
+
retry and CPS handoff on the same cookie jar.
|
|
10
|
+
- Identities: S4 files, textual identities, ephemeral identities, environment loading,
|
|
11
|
+
rekey and password change.
|
|
12
|
+
- `SQRL::Client::Transport` (Net::HTTP, cookie jar, TLS options; plain HTTP refused unless
|
|
13
|
+
`insecure: true`), `SQRL::Client::Capybara` helper, and the `sqrl-client` executable with
|
|
14
|
+
Netscape/JSON cookie export.
|
|
15
|
+
- A 23-case server conformance suite usable from Ruby, RSpec (`it_behaves_like "a SQRL
|
|
16
|
+
server"`) and the command line, with JUnit output.
|
|
17
|
+
- Requires Ruby 3.1+ and `http-cookie`.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel P. Clark
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# sqrl-client
|
|
2
|
+
|
|
3
|
+
A bot-capable [SQRL](https://www.grc.com/sqrl/sqrl.htm) client for Ruby: everything a
|
|
4
|
+
script, a test suite or an automated service needs to log in to a SQRL-enabled website
|
|
5
|
+
without a browser or a human. It is also the test driver for the `sqrl-*` server gems and
|
|
6
|
+
ships a conformance suite any SQRL server can be checked against.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
gem "sqrl-client"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
require "sqrl/client"
|
|
16
|
+
|
|
17
|
+
identity = SQRL::Client::Identity.load("bot.sqrl", password: ENV["SQRL_PASSWORD"])
|
|
18
|
+
agent = SQRL::Client::Agent.new(identity: identity)
|
|
19
|
+
result = agent.login("https://example.com/login") # a login page, a sqrl:// URL or a localhost:25519 link
|
|
20
|
+
|
|
21
|
+
result.authenticated? # => true
|
|
22
|
+
result.created? # => true on the first visit (the site created the account)
|
|
23
|
+
result.idk # => the site-specific public key, base64url
|
|
24
|
+
result.landing # => the response of following the CPS url= (logged-in page)
|
|
25
|
+
agent.http.get("https://example.com/account") # same cookie jar: already logged in
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Identities
|
|
29
|
+
|
|
30
|
+
| Call | Use |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `Identity.load(path, password:)` | an S4 identity file (binary `sqrldata` or `SQRLDATA` text), as exported by any SQRL client |
|
|
33
|
+
| `Identity.from_textual(text, rescue_code:, password:)` | import a base56 textual identity |
|
|
34
|
+
| `Identity.create(password:)` | a brand-new identity; `#rescue_code` is shown once |
|
|
35
|
+
| `Identity.ephemeral` / `Identity.ephemeral("seed")` | an identity straight from a random or deterministic IUK, no EnScrypt; ideal for tests |
|
|
36
|
+
| `Identity.from_env` | `SQRL_IUK_HEX`, or `SQRL_IDENTITY_FILE` + `SQRL_PASSWORD`/`SQRL_PASSWORD_FILE` (+ `SQRL_RESCUE_CODE`) |
|
|
37
|
+
|
|
38
|
+
`enable`, `remove` and `rekey!` need the Identity Unlock Key. Ephemeral and freshly created
|
|
39
|
+
identities have it; file identities get it from `rescue_code:` (slow: EnScrypt).
|
|
40
|
+
|
|
41
|
+
## The agent
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
agent = SQRL::Client::Agent.new(
|
|
45
|
+
identity: identity,
|
|
46
|
+
cps: true, # request a Client Provided Session and follow its url=
|
|
47
|
+
unknown_site: :create, # or :refuse to never create accounts
|
|
48
|
+
options: { sqrlonly: false, hardlock: false, noiptest: false },
|
|
49
|
+
alt_id: nil,
|
|
50
|
+
ask_handler: ->(ask) { ask.buttons.index { |b| b.label =~ /allow/i }.to_i + 1 },
|
|
51
|
+
insecure: false # true: accept plain http and qrl:// for loopback test servers only
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
agent.login(url) # query → (ask) → ident; creates / logs in / re-keys as the site answers
|
|
55
|
+
agent.query(url) # does the site know me? (state :unknown / :known / :previous_known)
|
|
56
|
+
agent.disable(url)
|
|
57
|
+
agent.enable(url, rescue_code: "…") # rescue code not needed for identities that hold the IUK
|
|
58
|
+
agent.remove(url, rescue_code: "…")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Every result carries the full `transcript` of the exchange (signatures are not recorded),
|
|
62
|
+
the TIF history, the cancel URL and the cookie jar. Failures raise `LoginFailed` with a
|
|
63
|
+
`reason` (`:disabled`, `:superseded`, `:unknown_site`, `:ip_mismatch`, `:transient`, …),
|
|
64
|
+
`AskRequired` when the site asks a question and no handler is set, `DiscoveryError` when no
|
|
65
|
+
`sqrl://` link is on the page, or `TransportError`.
|
|
66
|
+
|
|
67
|
+
Transient errors are retried with the fresh nut, previous identities are offered in turn
|
|
68
|
+
(`PREVIOUS_ID_MATCH` re-keys the account), `sin`/`ins` and `pins` are answered, and the CPS
|
|
69
|
+
`url=` is validated to point at the site before it is followed.
|
|
70
|
+
|
|
71
|
+
SQRL is `sqrl://` over HTTPS, and that is all the agent speaks by default. `insecure: true`
|
|
72
|
+
(CLI: `--insecure`) is a loopback-only testing mode that also accepts plain-http pages and
|
|
73
|
+
`qrl://` links, which the browser extension's test server and this repository's own spec
|
|
74
|
+
site use; `qrl://` is not part of the final SQRL specification and real clients refuse
|
|
75
|
+
it. A self-signed development certificate is trusted with `Transport.new(ca_file:)`
|
|
76
|
+
(CLI: `--ca-file`); `--no-verify-tls` turns verification off altogether.
|
|
77
|
+
|
|
78
|
+
## Capybara
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
visit "/login"
|
|
82
|
+
SQRL::Client::Capybara.login(page, identity: SQRL::Client::Identity.ephemeral("bot"))
|
|
83
|
+
expect(page).to have_content("Welcome")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The protocol runs out of band; the **browser** then visits the one-time CPS URL, exactly as a
|
|
87
|
+
native client would redirect the tab, so the browser's own session is the one logged in.
|
|
88
|
+
`cps: false` performs `ident` only and waits for the page's poller to redirect.
|
|
89
|
+
|
|
90
|
+
## Command line
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
sqrl-client identity create --out bot.sqrl --password-file pw.txt # prints the rescue code once
|
|
94
|
+
sqrl-client identity import --textual id.txt --rescue-code 1234-… --out bot.sqrl
|
|
95
|
+
sqrl-client identity show --identity bot.sqrl [--json]
|
|
96
|
+
sqrl-client identity export --identity bot.sqrl --format s4-text|textual|binary [--out FILE]
|
|
97
|
+
sqrl-client identity rekey --identity bot.sqrl --password … --rescue-code … # new IUK, old one kept as previous
|
|
98
|
+
sqrl-client identity change-password --identity bot.sqrl --password OLD --new-password NEW
|
|
99
|
+
sqrl-client identity previous --identity bot.sqrl # superseded identities, most recent first
|
|
100
|
+
sqrl-client login https://example.com/login --identity bot.sqrl --cookies-out jar.txt [--no-cps] [--alt-id ID] [--json]
|
|
101
|
+
sqrl-client query | disable | enable | remove URL … # enable/remove need --rescue-code
|
|
102
|
+
sqrl-client conformance https://example.com/login [--report junit.xml] [--feature ask=on] [--only CASE,…]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Identities come from `--identity FILE` with `--password`/`--password-file`, from the
|
|
106
|
+
`SQRL_IDENTITY_FILE`/`SQRL_PASSWORD`/`SQRL_PASSWORD_FILE`/`SQRL_RESCUE_CODE` variables, or
|
|
107
|
+
from `--iuk-hex`/`SQRL_IUK_HEX` for throwaway test identities. `--pw-seconds` and
|
|
108
|
+
`--rescue-seconds` shorten EnScrypt for test identities. Exit codes: 0 success, 1 the site
|
|
109
|
+
refused (protocol), 2 transport failure, 3 usage. Cookies are written in Netscape format
|
|
110
|
+
(`curl -b jar.txt`) or JSON (`--cookies-format json`).
|
|
111
|
+
|
|
112
|
+
## Conformance suite
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
report = SQRL::Client::Conformance::Runner.new(login_url: "https://example.com/login").run
|
|
116
|
+
puts report # PASS / FAIL / SKIP per case
|
|
117
|
+
report.passed?
|
|
118
|
+
report.to_junit_xml
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Cases cover account creation, login with and without CPS, disable/enable/remove, re-keying
|
|
122
|
+
and superseded identities, `ask`/`btn`, `sin`/`ins`, Alt-IDs, nut replay, unknown nuts,
|
|
123
|
+
tampered requests, wrong `server=` echoes, bad signatures, identity creation without
|
|
124
|
+
`suk`/`vuk`, one-time CPS tokens, transient recovery and concurrent logins. Feature flags
|
|
125
|
+
(`features: { ask: true, sin: true, cps_single_use: false, … }`) skip what a site does not
|
|
126
|
+
do; a custom `site:` adapter handles pages with unusual layouts.
|
|
127
|
+
|
|
128
|
+
In RSpec:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
require "sqrl/client/conformance/rspec"
|
|
132
|
+
|
|
133
|
+
RSpec.describe "my site" do
|
|
134
|
+
let(:login_url) { "http://127.0.0.1:9292/login" }
|
|
135
|
+
it_behaves_like "a SQRL server"
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Limits
|
|
140
|
+
|
|
141
|
+
Ruby cannot wipe memory reliably; key material lives in ordinary Strings. Sites that show
|
|
142
|
+
only a QR image (no `sqrl://` link anywhere in the page) are not supported yet. SQRL for
|
|
143
|
+
APIs is not specified yet and therefore not implemented.
|
|
144
|
+
|
|
145
|
+
## Testing
|
|
146
|
+
|
|
147
|
+
The gem's own specs boot a small Rack site backed by `SQRL::Server::Handler`; this
|
|
148
|
+
repository's integration suite (`spec/integration`) drives every example application with
|
|
149
|
+
this client over HTTPS, and `spec/interop` runs the conformance suite against the Rust
|
|
150
|
+
reference server. Run `bundle exec rake spec:sqrl-client`.
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT. See [LICENSE](LICENSE).
|
data/exe/sqrl-client
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SQRL
|
|
6
|
+
module Client
|
|
7
|
+
# The outcome of one {Agent} operation.
|
|
8
|
+
class LoginResult
|
|
9
|
+
# @return [Symbol] +:query+, +:ident+, +:disable+, +:enable+ or +:remove+
|
|
10
|
+
attr_reader :command
|
|
11
|
+
# @return [String] the +sqrl://+ URL that was used
|
|
12
|
+
attr_reader :sqrl_url
|
|
13
|
+
# @return [SQRL::Session::State] state after the last exchange
|
|
14
|
+
attr_reader :state
|
|
15
|
+
# @return [SQRL::Tif, nil] TIF of the last response
|
|
16
|
+
attr_reader :tif
|
|
17
|
+
# @return [Array<SQRL::Tif>] every TIF received, in order
|
|
18
|
+
attr_reader :tif_history
|
|
19
|
+
# @return [String] the identity's per-site public key, base64url
|
|
20
|
+
attr_reader :idk
|
|
21
|
+
# @return [String, nil] the validated CPS +url=+ when the server sent one
|
|
22
|
+
attr_reader :cps_url
|
|
23
|
+
# @return [Transport::Response, nil] the response of following the CPS URL
|
|
24
|
+
attr_reader :landing
|
|
25
|
+
# @return [Hash, nil] the last JSON answer of the status poll (non-CPS logins)
|
|
26
|
+
attr_reader :status
|
|
27
|
+
# @return [HTTP::CookieJar] the cookies, including those set by the landing page
|
|
28
|
+
attr_reader :cookies
|
|
29
|
+
# @return [Array<Hash>] one entry per wire exchange (signatures are not recorded)
|
|
30
|
+
attr_reader :transcript
|
|
31
|
+
# @return [SQRL::Ask, nil] the question the server posed, if any
|
|
32
|
+
attr_reader :ask
|
|
33
|
+
# @return [Integer, nil] the button that answered it
|
|
34
|
+
attr_reader :button
|
|
35
|
+
# @return [String, nil] the cancel URL offered by the site
|
|
36
|
+
attr_reader :cancel_url
|
|
37
|
+
|
|
38
|
+
# @api private
|
|
39
|
+
def initialize(command:, sqrl_url:, state:, tif:, tif_history:, idk:, cookies:, transcript:, created: false,
|
|
40
|
+
rekeyed: false, authenticated: false, cps_url: nil, landing: nil, status: nil, ask: nil,
|
|
41
|
+
button: nil, cancel_url: nil)
|
|
42
|
+
@command = command
|
|
43
|
+
@sqrl_url = sqrl_url
|
|
44
|
+
@state = state
|
|
45
|
+
@tif = tif
|
|
46
|
+
@tif_history = tif_history
|
|
47
|
+
@idk = idk
|
|
48
|
+
@cookies = cookies
|
|
49
|
+
@transcript = transcript
|
|
50
|
+
@created = created
|
|
51
|
+
@rekeyed = rekeyed
|
|
52
|
+
@authenticated = authenticated
|
|
53
|
+
@cps_url = cps_url
|
|
54
|
+
@landing = landing
|
|
55
|
+
@status = status
|
|
56
|
+
@ask = ask
|
|
57
|
+
@button = button
|
|
58
|
+
@cancel_url = cancel_url
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Whether the site accepted the login (or enable) and the session is established.
|
|
62
|
+
def authenticated? = @authenticated
|
|
63
|
+
# Whether this operation created the account on the site.
|
|
64
|
+
def created? = @created
|
|
65
|
+
# Whether this operation moved the site's account to a re-keyed identity.
|
|
66
|
+
def rekeyed? = @rekeyed
|
|
67
|
+
# Whether the site knows the identity (after a +query+).
|
|
68
|
+
def known? = state.known?
|
|
69
|
+
def disabled? = state.disabled?
|
|
70
|
+
|
|
71
|
+
# @return [Hash] a JSON-friendly summary (no secrets)
|
|
72
|
+
def to_h
|
|
73
|
+
{ command: command, sqrl_url: sqrl_url, state: state.kind, disabled: state.disabled?, tif: tif&.to_hex,
|
|
74
|
+
idk: idk, authenticated: authenticated?, created: created?, rekeyed: rekeyed?, cps_url: cps_url,
|
|
75
|
+
landing_status: landing&.status, landing_url: landing&.final_url, ask: ask&.message, button: button }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [String] a short description with secret material redacted
|
|
79
|
+
def inspect
|
|
80
|
+
"#<SQRL::Client::LoginResult #{command} #{state.kind} authenticated=#{authenticated?}>"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A headless SQRL client that performs whole transactions against a site: discovery of
|
|
85
|
+
# the login link, the +query+ → +ident+ dance (including account creation, re-keying,
|
|
86
|
+
# previous identities, +ask+ answers and +sin+/+ins+), the CPS handoff, and the account
|
|
87
|
+
# commands +disable+, +enable+ and +remove+.
|
|
88
|
+
class Agent
|
|
89
|
+
# Seconds between status polls for non-CPS logins.
|
|
90
|
+
DEFAULT_POLL_INTERVAL = 0.5
|
|
91
|
+
|
|
92
|
+
attr_reader :identity, :transport, :options, :alt_id, :logger
|
|
93
|
+
attr_accessor :cps, :follow_cps, :unknown_site, :ask_handler, :max_transient_retries, :poll, :poll_interval,
|
|
94
|
+
:poll_timeout
|
|
95
|
+
|
|
96
|
+
# @param identity [Identity]
|
|
97
|
+
# @param transport [#get, #post_form] defaults to a fresh {Transport}
|
|
98
|
+
# @param cps [Boolean] request a Client Provided Session (+url=+)
|
|
99
|
+
# @param follow_cps [Boolean] GET the CPS +url=+ with the agent's own cookie jar; set
|
|
100
|
+
# to false when something else (a browser) will visit it, since the token is one-time
|
|
101
|
+
# @param unknown_site [Symbol] +:create+ an account on sites that do not know the
|
|
102
|
+
# identity, or +:refuse+ to
|
|
103
|
+
# @param options [Hash] +sqrlonly:+, +hardlock:+, +noiptest:+
|
|
104
|
+
# @param alt_id [String, nil] SQRL Alt-ID (a different identity on the same site)
|
|
105
|
+
# @param ask_handler [#call, nil] receives the {SQRL::Ask} and returns the button
|
|
106
|
+
# number (0 for the plain OK, 1 or 2); without one an +ask+ raises {AskRequired}
|
|
107
|
+
# @param max_transient_retries [Integer]
|
|
108
|
+
# @param poll [Boolean] for non-CPS logins, poll the site's status URL when one was
|
|
109
|
+
# discovered
|
|
110
|
+
# @param poll_timeout [Numeric] seconds
|
|
111
|
+
# +insecure: true+ accepts the loopback-only +qrl://+ (plain http) testing mode and
|
|
112
|
+
# lets the default transport speak plain http to loopback hosts. Off by default:
|
|
113
|
+
# SQRL is +sqrl://+ over HTTPS.
|
|
114
|
+
def initialize(identity:, transport: nil, cps: true, follow_cps: true, unknown_site: :create, options: {},
|
|
115
|
+
alt_id: nil, ask_handler: nil, max_transient_retries: 2, poll: true,
|
|
116
|
+
poll_interval: DEFAULT_POLL_INTERVAL, poll_timeout: 10, logger: nil, insecure: false)
|
|
117
|
+
raise ArgumentError, "unknown_site must be :create or :refuse" unless %i[create refuse].include?(unknown_site)
|
|
118
|
+
|
|
119
|
+
@identity = identity
|
|
120
|
+
@insecure = insecure ? true : false
|
|
121
|
+
@transport = transport || Transport.new(logger: logger, allow_http: @insecure ? :loopback : false)
|
|
122
|
+
@cps = cps
|
|
123
|
+
@follow_cps = follow_cps
|
|
124
|
+
@unknown_site = unknown_site
|
|
125
|
+
@options = { sqrlonly: false, hardlock: false, noiptest: false }.merge(options)
|
|
126
|
+
@alt_id = alt_id
|
|
127
|
+
@ask_handler = ask_handler
|
|
128
|
+
@max_transient_retries = max_transient_retries
|
|
129
|
+
@poll = poll
|
|
130
|
+
@poll_interval = poll_interval
|
|
131
|
+
@poll_timeout = poll_timeout
|
|
132
|
+
@logger = logger
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The transport, for the bot's own requests after logging in.
|
|
136
|
+
alias http transport
|
|
137
|
+
|
|
138
|
+
# Whether plain-http / +qrl://+ loopback testing mode is on.
|
|
139
|
+
def insecure? = @insecure
|
|
140
|
+
|
|
141
|
+
# Log in (creating the account, or re-keying it, as the site's answer dictates).
|
|
142
|
+
#
|
|
143
|
+
# @param target [String] a login page URL, a +sqrl://+ URL or a
|
|
144
|
+
# +localhost:25519/<b64>+ CPS link
|
|
145
|
+
# @return [LoginResult]
|
|
146
|
+
# @raise [LoginFailed, AskRequired, DiscoveryError, TransportError]
|
|
147
|
+
def login(target)
|
|
148
|
+
run(target, :ident)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Ask the site whether it knows the identity, without logging in.
|
|
152
|
+
def query(target)
|
|
153
|
+
run(target, :query)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Disable SQRL login for the account on the site.
|
|
157
|
+
def disable(target)
|
|
158
|
+
run(target, :disable)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Re-enable SQRL login. Needs the IUK (ephemeral/created identities have it; file
|
|
162
|
+
# identities take +rescue_code:+).
|
|
163
|
+
def enable(target, rescue_code: nil)
|
|
164
|
+
run(target, :enable, rescue_code: rescue_code)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Remove the SQRL association from the account. Needs the IUK like {#enable}.
|
|
168
|
+
def remove(target, rescue_code: nil)
|
|
169
|
+
run(target, :remove, rescue_code: rescue_code)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Discover the login link for +target+ without talking SQRL.
|
|
173
|
+
#
|
|
174
|
+
# @return [Discovery::Result]
|
|
175
|
+
def discover(target)
|
|
176
|
+
direct = Discovery.from_target(target)
|
|
177
|
+
return direct if direct
|
|
178
|
+
|
|
179
|
+
page = @transport.get(target)
|
|
180
|
+
unless page.success?
|
|
181
|
+
raise TransportError.new("login page #{target} answered #{page.status}", url: target, status: page.status)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
Discovery.find(page.body, base_url: page.final_url)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def client_options
|
|
190
|
+
ClientOptions.new(cps: @cps, sqrlonly: @options[:sqrlonly], hardlock: @options[:hardlock],
|
|
191
|
+
noiptest: @options[:noiptest])
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def run(target, command, rescue_code: nil)
|
|
195
|
+
found = discover(target)
|
|
196
|
+
session = Session.new(@identity.unlocked, found.sqrl_url, options: client_options, alt_id: @alt_id,
|
|
197
|
+
insecure: @insecure)
|
|
198
|
+
ctx = Context.new(found, session)
|
|
199
|
+
transient = 0
|
|
200
|
+
|
|
201
|
+
loop do
|
|
202
|
+
query_until_settled(ctx)
|
|
203
|
+
break if command == :query
|
|
204
|
+
|
|
205
|
+
ctx.pre_state = session.state
|
|
206
|
+
answer_ask(ctx)
|
|
207
|
+
request = build_command(ctx, command, rescue_code)
|
|
208
|
+
exchange(ctx, request)
|
|
209
|
+
break unless session.state.transient?
|
|
210
|
+
|
|
211
|
+
transient += 1
|
|
212
|
+
next unless transient > @max_transient_retries
|
|
213
|
+
|
|
214
|
+
raise LoginFailed.new("the server kept answering with a transient error", reason: :transient,
|
|
215
|
+
tif: session.last_response.tif,
|
|
216
|
+
state: session.state)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
finish(ctx, command)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Small bag of per-transaction state. @api private
|
|
223
|
+
Context = Struct.new(:found, :session, :pre_state, :tifs, :transcript, :ask, :button, :landing, :status) do
|
|
224
|
+
def initialize(found, session)
|
|
225
|
+
super(found, session, nil, [], [], nil, nil, nil, nil)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Query until the server has classified the identity, retrying on transient errors
|
|
230
|
+
# and walking the previous identities when the current one is unknown.
|
|
231
|
+
def query_until_settled(ctx)
|
|
232
|
+
session = ctx.session
|
|
233
|
+
transient = 0
|
|
234
|
+
loop do
|
|
235
|
+
exchange(ctx, session.query)
|
|
236
|
+
state = session.state
|
|
237
|
+
case state.kind
|
|
238
|
+
when :transient
|
|
239
|
+
transient += 1
|
|
240
|
+
next if transient <= @max_transient_retries
|
|
241
|
+
|
|
242
|
+
raise LoginFailed.new("the server kept answering with a transient error", reason: :transient,
|
|
243
|
+
tif: session.last_response.tif, state: state)
|
|
244
|
+
when :unknown
|
|
245
|
+
next if session.has_previous? && session.try_next_previous
|
|
246
|
+
|
|
247
|
+
return
|
|
248
|
+
when :known, :previous_known
|
|
249
|
+
return
|
|
250
|
+
when :superseded
|
|
251
|
+
raise LoginFailed.new("this identity was superseded on the site by a newer one", reason: :superseded,
|
|
252
|
+
tif: session.last_response.tif, state: state)
|
|
253
|
+
else
|
|
254
|
+
reason = session.ip_check_failed? ? :ip_mismatch : :failed
|
|
255
|
+
raise LoginFailed.new(state.message || "query failed", reason: reason, tif: session.last_response.tif,
|
|
256
|
+
state: state)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def answer_ask(ctx)
|
|
262
|
+
ask = ctx.session.pending_ask
|
|
263
|
+
return unless ask
|
|
264
|
+
|
|
265
|
+
ctx.ask = ask
|
|
266
|
+
raise AskRequired, ask unless @ask_handler
|
|
267
|
+
|
|
268
|
+
btn = @ask_handler.call(ask).to_i
|
|
269
|
+
ctx.button = btn
|
|
270
|
+
ctx.session.set_button(btn)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def build_command(ctx, command, rescue_code)
|
|
274
|
+
session = ctx.session
|
|
275
|
+
state = session.state
|
|
276
|
+
case command
|
|
277
|
+
when :ident
|
|
278
|
+
if state.unknown? && @unknown_site == :refuse
|
|
279
|
+
raise LoginFailed.new("#{session.url.host} does not know this identity and unknown_site is :refuse",
|
|
280
|
+
reason: :unknown_site, tif: session.last_response.tif, state: state)
|
|
281
|
+
end
|
|
282
|
+
if state.disabled?
|
|
283
|
+
raise LoginFailed.new("SQRL login is disabled for this account on #{session.url.host}", reason: :disabled,
|
|
284
|
+
tif: session.last_response.tif, state: state)
|
|
285
|
+
end
|
|
286
|
+
session.ident
|
|
287
|
+
when :disable
|
|
288
|
+
session.disable
|
|
289
|
+
when :enable, :remove
|
|
290
|
+
iuk = state.previous_known? ? nil : unlock_key(rescue_code)
|
|
291
|
+
command == :enable ? session.enable(iuk) : session.remove(iuk)
|
|
292
|
+
else
|
|
293
|
+
raise ArgumentError, "unknown command #{command.inspect}"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def unlock_key(rescue_code)
|
|
298
|
+
return @identity.iuk if @identity.iuk?
|
|
299
|
+
return @identity.unlock_iuk!(rescue_code) if rescue_code
|
|
300
|
+
|
|
301
|
+
raise IdentityLocked,
|
|
302
|
+
"enable/remove need the Identity Unlock Key: pass rescue_code: or use an identity that has it"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def exchange(ctx, request)
|
|
306
|
+
response = @transport.post_form(request.url, request.body, { "Accept" => "text/plain" })
|
|
307
|
+
unless response.success?
|
|
308
|
+
raise TransportError.new("SQRL endpoint #{request.url} answered HTTP #{response.status}", url: request.url,
|
|
309
|
+
status: response.status)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
parsed = ctx.session.feed(response.body.strip)
|
|
313
|
+
ctx.tifs << parsed.tif
|
|
314
|
+
ctx.transcript << { command: request.command, url: request.url, client_text: request.client_text,
|
|
315
|
+
http_status: response.status, tif: parsed.tif.to_hex, response: parsed.params.to_h }
|
|
316
|
+
@logger&.debug { "[sqrl-client] #{request.command} -> tif=0x#{parsed.tif.to_hex} (#{ctx.session.state.kind})" }
|
|
317
|
+
parsed
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def finish(ctx, command)
|
|
321
|
+
session = ctx.session
|
|
322
|
+
state = session.state
|
|
323
|
+
tif = session.last_response&.tif
|
|
324
|
+
authenticated = false
|
|
325
|
+
created = false
|
|
326
|
+
rekeyed = false
|
|
327
|
+
cps_url = nil
|
|
328
|
+
|
|
329
|
+
case command
|
|
330
|
+
when :ident, :enable
|
|
331
|
+
unless !tif.error? && tif.id_match? && !(command == :ident && tif.sqrl_disabled?)
|
|
332
|
+
raise LoginFailed.new("#{command} failed: #{state.message || state.kind}", reason: failure_reason(session),
|
|
333
|
+
tif: tif, state: state)
|
|
334
|
+
end
|
|
335
|
+
authenticated = true
|
|
336
|
+
created = command == :ident && ctx.pre_state&.unknown?
|
|
337
|
+
rekeyed = ctx.pre_state&.previous_known? || false
|
|
338
|
+
when :disable
|
|
339
|
+
unless !tif.error? && tif.sqrl_disabled?
|
|
340
|
+
raise LoginFailed.new("disable failed: #{state.message || state.kind}", reason: failure_reason(session),
|
|
341
|
+
tif: tif, state: state)
|
|
342
|
+
end
|
|
343
|
+
when :remove
|
|
344
|
+
if tif.error? || tif.id_match?
|
|
345
|
+
raise LoginFailed.new("remove failed: #{state.message || state.kind}", reason: failure_reason(session),
|
|
346
|
+
tif: tif, state: state)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
if command != :query
|
|
351
|
+
cps_url = session.cps_url
|
|
352
|
+
if authenticated
|
|
353
|
+
if cps_url
|
|
354
|
+
ctx.landing = @transport.get(cps_url) if @follow_cps
|
|
355
|
+
elsif @poll && ctx.found.status_url
|
|
356
|
+
ctx.status = poll_status(ctx)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
LoginResult.new(command: command, sqrl_url: ctx.found.sqrl_url, state: state, tif: tif, tif_history: ctx.tifs,
|
|
362
|
+
idk: session.idk_b64, cookies: @transport.respond_to?(:cookies) ? @transport.cookies : nil,
|
|
363
|
+
transcript: ctx.transcript, created: created, rekeyed: rekeyed, authenticated: authenticated,
|
|
364
|
+
cps_url: cps_url, landing: ctx.landing, status: ctx.status, ask: ctx.ask, button: ctx.button,
|
|
365
|
+
cancel_url: session.cancel_url)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def failure_reason(session)
|
|
369
|
+
tif = session.last_response&.tif
|
|
370
|
+
return :unknown if tif.nil?
|
|
371
|
+
return :superseded if tif.identity_superseded?
|
|
372
|
+
return :disabled if tif.sqrl_disabled?
|
|
373
|
+
return :ip_mismatch if session.ip_check_failed?
|
|
374
|
+
return :client_failure if tif.client_failure?
|
|
375
|
+
return :not_supported if tif.function_not_supported?
|
|
376
|
+
|
|
377
|
+
:command_failed
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Poll the discovered status URL until it reports the login, following its +next+
|
|
381
|
+
# URL (which completes a cookie-based login) when it gives one.
|
|
382
|
+
def poll_status(ctx)
|
|
383
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @poll_timeout
|
|
384
|
+
last = nil
|
|
385
|
+
loop do
|
|
386
|
+
response = @transport.get(ctx.found.status_url, { "Accept" => "application/json" })
|
|
387
|
+
last = response.json || {}
|
|
388
|
+
if last["authenticated"]
|
|
389
|
+
ctx.landing = @transport.get(URI.join(ctx.found.status_url, last["next"]).to_s) if last["next"]
|
|
390
|
+
return last
|
|
391
|
+
end
|
|
392
|
+
return last if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
393
|
+
|
|
394
|
+
sleep @poll_interval
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
module Client
|
|
5
|
+
# Drives a Capybara session through a SQRL login, the way a native client on the same
|
|
6
|
+
# machine would: the protocol runs out of band with an {Agent}, then the *browser*
|
|
7
|
+
# visits the server's CPS +url=+, so the browser's own session cookie is the one that
|
|
8
|
+
# gets logged in.
|
|
9
|
+
#
|
|
10
|
+
# visit "/login"
|
|
11
|
+
# SQRL::Client::Capybara.login(page, identity: SQRL::Client::Identity.ephemeral("bot"))
|
|
12
|
+
# expect(page).to have_content("Welcome")
|
|
13
|
+
module Capybara
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @param page [Capybara::Session] showing a page with a SQRL login link
|
|
17
|
+
# @param identity [Identity]
|
|
18
|
+
# @param cps [Boolean] +true+: visit the CPS URL in the browser; +false+: perform
|
|
19
|
+
# +ident+ only and wait for the page's own poller to navigate
|
|
20
|
+
# @param wait [Numeric] seconds to wait for the non-CPS redirect
|
|
21
|
+
# @param agent_opts [Hash] passed to {Agent#initialize}
|
|
22
|
+
# @return [LoginResult]
|
|
23
|
+
def login(page, identity:, cps: true, wait: 10, **agent_opts)
|
|
24
|
+
found = Discovery.find(page.html, base_url: page.current_url)
|
|
25
|
+
agent = Agent.new(identity: identity, cps: cps, follow_cps: false, poll: false, **agent_opts)
|
|
26
|
+
result = agent.login(found.sqrl_url)
|
|
27
|
+
if cps
|
|
28
|
+
raise LoginFailed.new("the server sent no CPS url=", reason: :no_cps, tif: result.tif) unless result.cps_url
|
|
29
|
+
|
|
30
|
+
page.visit(result.cps_url)
|
|
31
|
+
else
|
|
32
|
+
start_path = page.current_path
|
|
33
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
|
|
34
|
+
sleep 0.1 while page.current_path == start_path && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
35
|
+
end
|
|
36
|
+
result
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Run +disable+/+enable+/+remove+ against the page's login link.
|
|
40
|
+
def command(page, command, identity:, rescue_code: nil, **agent_opts)
|
|
41
|
+
found = Discovery.find(page.html, base_url: page.current_url)
|
|
42
|
+
agent = Agent.new(identity: identity, cps: false, poll: false, **agent_opts)
|
|
43
|
+
case command
|
|
44
|
+
when :disable then agent.disable(found.sqrl_url)
|
|
45
|
+
when :enable then agent.enable(found.sqrl_url, rescue_code: rescue_code)
|
|
46
|
+
when :remove then agent.remove(found.sqrl_url, rescue_code: rescue_code)
|
|
47
|
+
else raise ArgumentError, "command must be :disable, :enable or :remove"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|