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