touchque 5.0.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 +105 -0
- data/LICENSE +21 -0
- data/README.md +383 -0
- data/lib/touchque/agent.rb +211 -0
- data/lib/touchque/agent_card.rb +159 -0
- data/lib/touchque/approvals.rb +214 -0
- data/lib/touchque/client.rb +81 -0
- data/lib/touchque/code_reply.rb +53 -0
- data/lib/touchque/cross_app_access.rb +204 -0
- data/lib/touchque/crypto.rb +215 -0
- data/lib/touchque/errors.rb +246 -0
- data/lib/touchque/flow.rb +362 -0
- data/lib/touchque/hosted_login.rb +401 -0
- data/lib/touchque/http.rb +127 -0
- data/lib/touchque/login_session.rb +86 -0
- data/lib/touchque/org_agent.rb +398 -0
- data/lib/touchque/rack/relay.rb +75 -0
- data/lib/touchque/rails/guard.rb +132 -0
- data/lib/touchque/rails/hosted_login.rb +143 -0
- data/lib/touchque/rails/sessions_controller.rb +12 -0
- data/lib/touchque/railtie.rb +34 -0
- data/lib/touchque/relay.rb +533 -0
- data/lib/touchque/resources/actions.rb +212 -0
- data/lib/touchque/resources/agents.rb +251 -0
- data/lib/touchque/resources/device_risk.rb +130 -0
- data/lib/touchque/resources/identity.rb +331 -0
- data/lib/touchque/resources/login.rb +325 -0
- data/lib/touchque/resources/mobile.rb +115 -0
- data/lib/touchque/resources/people.rb +237 -0
- data/lib/touchque/resources/qr_pairings.rb +82 -0
- data/lib/touchque/resources/safe_guard.rb +87 -0
- data/lib/touchque/resources/webhook.rb +177 -0
- data/lib/touchque/safe_approval.rb +283 -0
- data/lib/touchque/security_events.rb +162 -0
- data/lib/touchque/util.rb +260 -0
- data/lib/touchque/version.rb +5 -0
- data/lib/touchque.rb +124 -0
- metadata +83 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Touchque
|
|
4
|
+
module Resources
|
|
5
|
+
# Touchque Mobile SDK, your backend's side: your own iOS/Android app is the approver
|
|
6
|
+
# of your users (white-label); they never install the Touchque app.
|
|
7
|
+
#
|
|
8
|
+
# 1. A user signed in to your app turns approvals on: create_enrollment gives a
|
|
9
|
+
# one-time token; your app hands it to the SDK (Touchque.enroll).
|
|
10
|
+
# 2. Ask for approvals as always (login.request, start, the guards).
|
|
11
|
+
# 3. Without your APNs/FCM keys in update_settings, Touchque sends you a signed
|
|
12
|
+
# MOBILE_PUSH webhook per request: verify it, then Mobile.push_from_webhook gives
|
|
13
|
+
# the APNs payload / FCM data to send with your own credentials.
|
|
14
|
+
class Mobile
|
|
15
|
+
SETTINGS = {
|
|
16
|
+
"ios_app_ids" => "iosAppIds", "android_packages" => "androidPackages", "android_certs" => "androidCerts",
|
|
17
|
+
"attestation" => "attestation", "enroll_ttl_seconds" => "enrollTtlSeconds", "apns" => "apns", "fcm" => "fcm"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(http)
|
|
21
|
+
@http = http
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A one-time enrollment token for a user you signed in. Whoever spends it becomes
|
|
25
|
+
# the user's approver, so authenticate the user first. APIError already_enrolled
|
|
26
|
+
# (pass replace: true) or account_frozen.
|
|
27
|
+
def create_enrollment(external_username, replace: false, ttl_seconds: nil)
|
|
28
|
+
raise ConfigError, "mobile.create_enrollment() needs the user (external_username)" if external_username.to_s.empty?
|
|
29
|
+
|
|
30
|
+
body = { "externalUsername" => external_username }
|
|
31
|
+
body["replace"] = true if replace
|
|
32
|
+
body["ttlSeconds"] = Integer(ttl_seconds) unless ttl_seconds.nil?
|
|
33
|
+
@http.post("/mobile/enrollments", body)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The devices inside your app (default: active ones), newest first: {"devices", "nextCursor"}.
|
|
37
|
+
# status: active, revoked or all.
|
|
38
|
+
def list_devices(external_username: nil, status: nil, limit: nil, cursor: nil)
|
|
39
|
+
@http.get("/mobile/devices", Util.compact({ "externalUsername" => external_username, "status" => status, "limit" => limit, "cursor" => cursor }))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def get_device(device_id)
|
|
43
|
+
raise ConfigError, "mobile.get_device() needs the device id" if device_id.to_s.empty?
|
|
44
|
+
|
|
45
|
+
@http.get("/mobile/devices/#{Util.seg(device_id)}")["device"]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Stops a device from approving (the user has no approver until you enrol again).
|
|
49
|
+
def remove_device(device_id)
|
|
50
|
+
raise ConfigError, "mobile.remove_device() needs the device id" if device_id.to_s.empty?
|
|
51
|
+
|
|
52
|
+
@http.delete("/mobile/devices/#{Util.seg(device_id)}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A harmless push to the device, the way a request would reach it: "via" touchque
|
|
56
|
+
# (your key for its platform) or webhook (MOBILE_PUSH, kind test); "ok": APNs / FCM took it.
|
|
57
|
+
def test_push(device_id)
|
|
58
|
+
raise ConfigError, "mobile.test_push() needs the device id" if device_id.to_s.empty?
|
|
59
|
+
|
|
60
|
+
@http.post("/mobile/devices/#{Util.seg(device_id)}/test-push", {})
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def settings
|
|
64
|
+
@http.get("/mobile/settings")["settings"]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Which apps may enrol, whether they must prove themselves, and your push keys. Only
|
|
68
|
+
# the keys given change: ios_app_ids, android_packages, android_certs ({package =>
|
|
69
|
+
# [SHA-256 of its signing certificate]}), attestation (off | optional | required),
|
|
70
|
+
# enroll_ttl_seconds, apns ({keyId:, teamId:, topic:, key: the .p8 text, production:}:
|
|
71
|
+
# Touchque then sends the iOS push itself), fcm ({serviceAccount: the JSON}: the
|
|
72
|
+
# Android push); nil removes a key. The keys are never shown back.
|
|
73
|
+
def update_settings(**changes)
|
|
74
|
+
unknown = changes.keys.map(&:to_s) - SETTINGS.keys
|
|
75
|
+
raise ConfigError, "mobile.update_settings(): unknown setting #{unknown.join(", ")}" unless unknown.empty?
|
|
76
|
+
|
|
77
|
+
@http.put("/mobile/settings", changes.to_h { |k, v| [SETTINGS[k.to_s], v] })["settings"]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The push to send for a verified MOBILE_PUSH webhook, or nil for any other event:
|
|
81
|
+
# deviceId, externalUsername, platform, pushToken (nil: send by your own user → device
|
|
82
|
+
# mapping), requestId, expiresAt, kind, alert (English, translate it), apns (the APNs
|
|
83
|
+
# payload: alert + the custom key "touchque") and fcmData. No secret, no details, no number.
|
|
84
|
+
def self.push_from_webhook(event)
|
|
85
|
+
event = Util.stringify(event) if event
|
|
86
|
+
return nil unless event.is_a?(Hash) && event["event"] == "MOBILE_PUSH"
|
|
87
|
+
|
|
88
|
+
data = event["data"].is_a?(Hash) ? event["data"] : {}
|
|
89
|
+
touchque = data["touchque"].is_a?(String) ? data["touchque"] : Util.json(data["touchque"] || {})
|
|
90
|
+
alert = event["alert"]
|
|
91
|
+
aps = { "sound" => "default", "interruption-level" => "time-sensitive" }
|
|
92
|
+
aps["alert"] = alert if alert
|
|
93
|
+
{
|
|
94
|
+
"deviceId" => event["deviceId"],
|
|
95
|
+
"externalUsername" => event["externalUsername"],
|
|
96
|
+
"platform" => event["platform"] == "android" ? "android" : "ios",
|
|
97
|
+
"pushToken" => event["pushToken"].is_a?(String) ? event["pushToken"] : nil,
|
|
98
|
+
"requestId" => event["requestId"],
|
|
99
|
+
"expiresAt" => event["expiresAt"],
|
|
100
|
+
# test: a test push sent from the dashboard
|
|
101
|
+
"kind" => %w[notice test].include?(event["kind"]) ? event["kind"] : "approval",
|
|
102
|
+
"alert" => alert,
|
|
103
|
+
"apns" => { "aps" => aps, "touchque" => touchque },
|
|
104
|
+
"fcmData" => { "touchque" => touchque }
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# True once a push is past its request's expiry (don't send it any more).
|
|
109
|
+
def self.push_expired?(push, now = Time.now)
|
|
110
|
+
at = Util.parse_time(push["expiresAt"])
|
|
111
|
+
!at.nil? && at <= now.to_f
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Touchque
|
|
4
|
+
module Resources
|
|
5
|
+
# Silent sign-ins: ask Touchque's risk engine whether a sign-in needs the phone at
|
|
6
|
+
# all, and manage the browsers it knows for a user. A browser becomes known when the
|
|
7
|
+
# user approves a request that carried its device-bound key proof; later +check+ with
|
|
8
|
+
# a fresh proof from the same browser answers "prompt" => false when nothing about
|
|
9
|
+
# the sign-in is new. Nothing goes to the phone.
|
|
10
|
+
class Risk
|
|
11
|
+
ASSURANCE = %w[high normal low].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(http)
|
|
14
|
+
@http = http
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Reads a POST /risk/check answer, failing closed: "prompt" is false ONLY when the
|
|
18
|
+
# answer says exactly prompt: false. Malformed reasons become [], an unknown or
|
|
19
|
+
# missing assurance is "normal".
|
|
20
|
+
def self.parse_check(raw)
|
|
21
|
+
body = raw.is_a?(Hash) ? raw : {}
|
|
22
|
+
reasons = (body["reasons"].is_a?(Array) ? body["reasons"] : []).filter_map do |r|
|
|
23
|
+
next unless r.is_a?(Hash) && r["code"].is_a?(String)
|
|
24
|
+
|
|
25
|
+
{ "code" => r["code"], "text" => r["text"].is_a?(String) ? r["text"] : "" }
|
|
26
|
+
end
|
|
27
|
+
out = {
|
|
28
|
+
"prompt" => !(body.key?("prompt") && body["prompt"] == false),
|
|
29
|
+
"reasons" => reasons,
|
|
30
|
+
"assurance" => ASSURANCE.include?(body["assurance"]) ? body["assurance"] : "normal",
|
|
31
|
+
"silentMode" => body["silentMode"] == "shadow" ? "shadow" : "enforce"
|
|
32
|
+
}
|
|
33
|
+
# "Why?": the check's case code, to show the user.
|
|
34
|
+
out["caseCode"] = body["caseCode"] if body["caseCode"].is_a?(String)
|
|
35
|
+
out
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Should this sign-in ask the phone? Skip the push only when "prompt" is false;
|
|
39
|
+
# on any error, ask as usual. A critical action always asks.
|
|
40
|
+
# max_age_seconds: your own limit on how long ago the last phone approval in this
|
|
41
|
+
# browser may be (0 = always ask). context: for your Touchque Actions only.
|
|
42
|
+
# device_risk_request_id: the page's identification (deviceRisk.identify), or device_risk:
|
|
43
|
+
# the page's report (deviceRisk.collect); a device signal is never silent.
|
|
44
|
+
def check(external_username, type: nil, client_ip: nil, user_agent: nil, browser_proof: nil,
|
|
45
|
+
client_signals: nil, max_age_seconds: nil, context: nil, device_risk: nil, device_risk_request_id: nil)
|
|
46
|
+
raise ConfigError, "risk.check() needs the user (external_username)" if external_username.to_s.empty?
|
|
47
|
+
if !max_age_seconds.nil? && (!max_age_seconds.is_a?(Integer) || max_age_seconds.negative?)
|
|
48
|
+
raise ConfigError, "risk.check(): max_age_seconds must be an integer >= 0 (0 = always ask)"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
body = { "externalUsername" => external_username }
|
|
52
|
+
{ "type" => type, "clientIp" => client_ip, "userAgent" => user_agent, "browserProof" => browser_proof,
|
|
53
|
+
"clientSignals" => client_signals, "deviceRisk" => device_risk,
|
|
54
|
+
"deviceRiskRequestId" => device_risk_request_id }.each { |k, v| body[k] = v if v.is_a?(String) && !v.empty? }
|
|
55
|
+
body["maxAgeSeconds"] = max_age_seconds unless max_age_seconds.nil?
|
|
56
|
+
body["context"] = Util.stringify(context) if context.is_a?(Hash) && !context.empty?
|
|
57
|
+
self.class.parse_check(@http.post("/risk/check", body))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The browsers Touchque knows for a user: {"browsers" => [...]}.
|
|
61
|
+
def browsers(external_username)
|
|
62
|
+
raise ConfigError, "risk.browsers() needs the user (external_username)" if external_username.to_s.empty?
|
|
63
|
+
|
|
64
|
+
res = @http.get("/risk/browsers", { "externalUsername" => external_username })
|
|
65
|
+
{ "browsers" => res["browsers"].is_a?(Array) ? res["browsers"] : [] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Forgets one browser (+thumbprint+) or all of a user's browsers: {"forgotten" => n}.
|
|
69
|
+
def forget_browsers(external_username, thumbprint: nil)
|
|
70
|
+
raise ConfigError, "risk.forget_browsers() needs the user (external_username)" if external_username.to_s.empty?
|
|
71
|
+
|
|
72
|
+
@http.post("/risk/browsers/forget", Util.compact({ "externalUsername" => external_username, "thumbprint" => thumbprint }))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Never Locked Out: move a user whose phone is lost, dead or replaced to a new phone
|
|
77
|
+
# without a help desk. Users name colleagues who vouch for them (set_vouchers, behind
|
|
78
|
+
# an approval on their phone); when the phone is gone, after the password: start (show
|
|
79
|
+
# "bindUri" as a QR for the Touchque app on the new phone), poll status and show each
|
|
80
|
+
# voucher's "code"; after a cooling-off nobody vetoed the account is on the new phone.
|
|
81
|
+
class Recovery
|
|
82
|
+
OPEN_STATUSES = %w[AWAITING_DEVICE VOUCHING COOLING].freeze
|
|
83
|
+
|
|
84
|
+
def initialize(http)
|
|
85
|
+
@http = http
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Still waiting for the new phone, the vouchers or the cooling-off.
|
|
89
|
+
def self.open?(recovery)
|
|
90
|
+
OPEN_STATUSES.include?(recovery.is_a?(Hash) ? recovery["status"] : nil)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def vouchers(external_username)
|
|
94
|
+
require!("vouchers", "external_username", external_username)
|
|
95
|
+
@http.get("/recovery/vouchers", { "externalUsername" => external_username })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Replaces the user's vouchers (at most 5 are kept). Ask for the user's phone first.
|
|
99
|
+
def set_vouchers(external_username, vouchers)
|
|
100
|
+
require!("set_vouchers", "external_username", external_username)
|
|
101
|
+
raise ConfigError, "recovery.set_vouchers(): every voucher must be a string" unless vouchers.all?(String)
|
|
102
|
+
|
|
103
|
+
@http.post("/recovery/vouchers", { "externalUsername" => external_username, "vouchers" => vouchers.to_a })
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Starts moving the user to a new phone. Call it only after the first factor. APIError
|
|
107
|
+
# with code not_enough_vouchers, recovery_in_progress, blocked, frozen or rate_limited.
|
|
108
|
+
def start(external_username, client_ip: nil, user_agent: nil, display_name: nil, fallback_vouchers: nil,
|
|
109
|
+
required: nil, cool_off_minutes: nil, details: nil, notice_details: nil)
|
|
110
|
+
require!("start", "the user (external_username)", external_username)
|
|
111
|
+
{ "required" => required, "cool_off_minutes" => cool_off_minutes }.each do |name, v|
|
|
112
|
+
next if v.nil?
|
|
113
|
+
raise ConfigError, "recovery.start(): #{name} must be a positive integer" unless v.is_a?(Integer) && v.positive?
|
|
114
|
+
end
|
|
115
|
+
body = { "externalUsername" => external_username }
|
|
116
|
+
body.merge!(Util.compact({ "clientIp" => client_ip, "userAgent" => user_agent, "displayName" => display_name }))
|
|
117
|
+
body["fallbackVouchers"] = fallback_vouchers.to_a if fallback_vouchers && !fallback_vouchers.empty?
|
|
118
|
+
body["required"] = required unless required.nil?
|
|
119
|
+
body["coolOffMinutes"] = cool_off_minutes unless cool_off_minutes.nil?
|
|
120
|
+
body["details"] = Util.stringify(details) if details && !details.empty?
|
|
121
|
+
body["noticeDetails"] = Util.stringify(notice_details) if notice_details && !notice_details.empty?
|
|
122
|
+
@http.post("/recovery/start", body)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The recovery now, with its receipt chain. Poll it while the user waits.
|
|
126
|
+
def status(recovery_id)
|
|
127
|
+
require!("status", "recovery_id", recovery_id)
|
|
128
|
+
@http.get("/recovery/#{Util.seg(recovery_id)}")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# A user's recoveries, or (open: true) every running one of your workspace.
|
|
132
|
+
def list(external_username: nil, open: false)
|
|
133
|
+
if external_username.to_s.empty? && !open
|
|
134
|
+
raise ConfigError, "recovery.list() needs external_username or open: true"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
res = @http.get("/recovery", { "externalUsername" => external_username.to_s.empty? ? nil : external_username, "open" => open ? "true" : nil })
|
|
138
|
+
res["recoveries"].is_a?(Array) ? res["recoveries"] : []
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Stops a running recovery (e.g. an admin who does not believe it). Does not freeze the user.
|
|
142
|
+
def veto(recovery_id, by: nil, reason: nil)
|
|
143
|
+
require!("veto", "recovery_id", recovery_id)
|
|
144
|
+
@http.post("/recovery/#{Util.seg(recovery_id)}/veto", Util.compact({ "by" => by, "reason" => reason }))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def require!(method, what, value)
|
|
150
|
+
raise ConfigError, "recovery.#{method}() needs #{what}" if value.to_s.empty?
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Help desk: IT working on a person's account in the open. open before your help desk
|
|
155
|
+
# acts on someone's account (their phone gets a calm notice); verify before a sensitive
|
|
156
|
+
# action (a 2-digit "challengeCode" the agent reads to the person, who types it and
|
|
157
|
+
# approves on their phone); spend right before the action. No phone (code no_phone,
|
|
158
|
+
# route never_locked_out): the person moves to a new phone with Never Locked Out.
|
|
159
|
+
class Assist
|
|
160
|
+
def initialize(http)
|
|
161
|
+
@http = http
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The check of +request_id+ passed on the person's phone and was not used yet.
|
|
165
|
+
def self.check_passed?(session, request_id)
|
|
166
|
+
check = (session["checks"] || []).find { |c| c["requestId"] == request_id }
|
|
167
|
+
!check.nil? && check["outcome"] == "verified" && check["spentAt"].nil?
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Starts helping a person: agent is {name:, title:, ref: your id for the agent}.
|
|
171
|
+
# A session of the same agent for the same person is replaced.
|
|
172
|
+
def open(external_username, agent:, reason: nil, ttl_minutes: nil, notify: true)
|
|
173
|
+
agent = Util.stringify(agent || {})
|
|
174
|
+
if external_username.to_s.empty? || agent["name"].to_s.empty?
|
|
175
|
+
raise ConfigError, "assist.open() needs external_username and agent name"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
body = { "externalUsername" => external_username, "agent" => Util.compact(agent) }
|
|
179
|
+
body["reason"] = reason unless reason.nil?
|
|
180
|
+
body["ttlMinutes"] = ttl_minutes unless ttl_minutes.nil?
|
|
181
|
+
body["notify"] = false unless notify
|
|
182
|
+
@http.post("/assist", body)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The session and its checks. Poll it while a check is pending.
|
|
186
|
+
def get(id)
|
|
187
|
+
require!("get", "the session id", id)
|
|
188
|
+
@http.get(path(id))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# A person's sessions, or every open one, newest first.
|
|
192
|
+
def list(external_username: nil, open: false)
|
|
193
|
+
raise ConfigError, "assist.list() needs external_username or open: true" if external_username.to_s.empty? && !open
|
|
194
|
+
|
|
195
|
+
@http.get("/assist", { "externalUsername" => external_username.to_s.empty? ? nil : external_username, "open" => open ? "true" : nil })["sessions"] || []
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Asks the person's phone to confirm +action+ (reset_2fa, reset_password,
|
|
199
|
+
# activation_link, recovery_link, unlock, new_phone, remove_passkey or your own).
|
|
200
|
+
def verify(id, action, action_text: nil, details: nil)
|
|
201
|
+
require!("verify", "the session id and an action", id, action)
|
|
202
|
+
body = { "action" => action }
|
|
203
|
+
body["actionText"] = action_text if action_text && !action_text.empty?
|
|
204
|
+
body["details"] = Util.stringify(details) if details && !details.empty?
|
|
205
|
+
@http.post(path(id, "/verify"), body)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Uses a passed check for its action, right before you take it; check_required (428) otherwise.
|
|
209
|
+
def spend(id, request_id, action)
|
|
210
|
+
require!("spend", "the session id, request_id and action", id, request_id, action)
|
|
211
|
+
@http.post(path(id, "/spend"), { "requestId" => request_id, "action" => action })
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Lifts the person's approval freeze with a passed check for "unlock".
|
|
215
|
+
def unfreeze(id, request_id)
|
|
216
|
+
require!("unfreeze", "the session id and request_id", id, request_id)
|
|
217
|
+
@http.post(path(id, "/unfreeze"), { "requestId" => request_id })
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Stops helping; checks still waiting on the phone expire.
|
|
221
|
+
def end(id, by: nil, reason: nil)
|
|
222
|
+
require!("end", "the session id", id)
|
|
223
|
+
@http.post(path(id, "/end"), Util.compact({ "by" => by, "reason" => reason }))
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def path(id, rest = "")
|
|
229
|
+
"/assist/#{Util.seg(id)}#{rest}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def require!(method, what, *values)
|
|
233
|
+
raise ConfigError, "assist.#{method}() needs #{what}" if values.any? { |v| v.to_s.empty? }
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Touchque
|
|
4
|
+
module Resources
|
|
5
|
+
# Pairing a browser by QR code: your page shows a QR code, the person scans it with
|
|
6
|
+
# the Touchque app, and the phone that scanned is the one asked to approve.
|
|
7
|
+
#
|
|
8
|
+
# 1. Your page gets the thumbprint of its browser key (@touchque/web
|
|
9
|
+
# browserKey.thumbprint()) and sends it to your server.
|
|
10
|
+
# 2. create(key_thumbprint, client_ip:, user_agent:) registers a pairing and returns
|
|
11
|
+
# "uri" (touchque://pair-browser?...): show it as a QR code. It can be scanned once,
|
|
12
|
+
# for ttl_seconds (default 120). The nonce in it never leaves your server and the
|
|
13
|
+
# QR: Touchque keeps only its hash.
|
|
14
|
+
# 3. Poll get(pairing_id): "scanned" once a phone holding one of your users' accounts
|
|
15
|
+
# read it ("externalUsername", "proximity"), "confirmed" once the person went on,
|
|
16
|
+
# "reported" when they said someone told them to scan it (their 2FA freezes).
|
|
17
|
+
# 4. login.request(user, qr_pairing: pairing_id) asks that phone, once, with number
|
|
18
|
+
# matching; the browser key's fingerprint is one of the rows the phone signs. When
|
|
19
|
+
# your own checks say no, refuse(pairing_id, reason:, message:) and the phone
|
|
20
|
+
# shows why.
|
|
21
|
+
class QrPairings
|
|
22
|
+
# What refuse may tell the phone.
|
|
23
|
+
REFUSAL_REASONS = %w[policy device_not_approved too_many_browsers rate_limited no_account other].freeze
|
|
24
|
+
|
|
25
|
+
B64URL_43 = /\A[A-Za-z0-9_-]{43}\z/
|
|
26
|
+
|
|
27
|
+
# What the QR code says: touchque://pair-browser?v=1&p=<pairing>&n=<nonce>&k=<key thumbprint>.
|
|
28
|
+
def self.uri(pairing_id, nonce, key_thumbprint)
|
|
29
|
+
"touchque://pair-browser?v=1&p=#{Util.seg(pairing_id)}&n=#{Util.seg(nonce)}&k=#{Util.seg(key_thumbprint)}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(http)
|
|
33
|
+
@http = http
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Registers a pairing for one browser: {"pairingId", "expiresAt", "nonce", "uri"};
|
|
37
|
+
# show "uri" as a QR code. +browser+ ("Chrome 155") and +os+ ("macOS 15") are shown
|
|
38
|
+
# on the phone; +ttl_seconds+ is 30 to 300.
|
|
39
|
+
def create(key_thumbprint, client_ip: nil, user_agent: nil, browser: nil, os: nil, ttl_seconds: nil)
|
|
40
|
+
unless key_thumbprint.is_a?(String) && key_thumbprint.match?(B64URL_43)
|
|
41
|
+
raise ConfigError, "qr_pairings.create needs key_thumbprint: the RFC 7638 thumbprint of the browser key, base64url (43 characters)"
|
|
42
|
+
end
|
|
43
|
+
if !ttl_seconds.nil? && !(ttl_seconds.is_a?(Integer) && ttl_seconds.between?(30, 300))
|
|
44
|
+
raise ConfigError, "qr_pairings.create: ttl_seconds must be 30 to 300"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
nonce = Util.b64url(SecureRandom.random_bytes(32))
|
|
48
|
+
body = { "nonceHash" => Util.b64url(Util.sha256(nonce)), "keyThumbprint" => key_thumbprint }
|
|
49
|
+
{ "clientIp" => client_ip, "userAgent" => user_agent, "browser" => browser, "os" => os, "ttlSeconds" => ttl_seconds }.each do |k, v|
|
|
50
|
+
body[k] = v unless v.nil? || v == ""
|
|
51
|
+
end
|
|
52
|
+
created = @http.post("/qr-pairings", body)
|
|
53
|
+
created.merge("nonce" => nonce, "uri" => self.class.uri(created["pairingId"], nonce, key_thumbprint))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Where a pairing stands; once scanned, who scanned it and how near the phone was.
|
|
57
|
+
def get(pairing_id)
|
|
58
|
+
@http.get(path(pairing_id, "get"))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Your own checks refused a scanned pairing; the phone shows +message+. Only a
|
|
62
|
+
# "scanned" or "confirmed" pairing can be refused (409 not_open otherwise).
|
|
63
|
+
def refuse(pairing_id, reason:, message:, case_code: nil, device_code: nil)
|
|
64
|
+
raise ConfigError, "qr_pairings.refuse: reason must be one of #{REFUSAL_REASONS.join(", ")}" unless REFUSAL_REASONS.include?(reason.to_s)
|
|
65
|
+
raise ConfigError, "qr_pairings.refuse needs message: what the phone shows (up to 300 characters)" if message.to_s.strip.empty?
|
|
66
|
+
|
|
67
|
+
body = { "reason" => reason.to_s, "message" => message }
|
|
68
|
+
body["caseCode"] = case_code unless case_code.to_s.empty?
|
|
69
|
+
body["deviceCode"] = device_code unless device_code.to_s.empty?
|
|
70
|
+
@http.post(path(pairing_id, "refuse", "/outcome"), body)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def path(pairing_id, method, rest = "")
|
|
76
|
+
raise ConfigError, "qr_pairings.#{method} needs the pairing id" if pairing_id.to_s.empty?
|
|
77
|
+
|
|
78
|
+
"/qr-pairings/#{Util.seg(pairing_id)}#{rest}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Touchque
|
|
4
|
+
module Resources
|
|
5
|
+
# Safe Guard: a Safe transaction approved on the approver's phone for TouchqueSafeGuard (the
|
|
6
|
+
# guard on your Safe). You send the raw transaction; the phone gets the raw fields only,
|
|
7
|
+
# decodes them itself, shows its own rows and signs, with a key only its biometrics unlock, a
|
|
8
|
+
# 105-byte approval of the hash it computed. Touchque never holds a key that can approve.
|
|
9
|
+
#
|
|
10
|
+
# 1. request(user, type, safe_tx) asks the phone (same pipeline as login.request: risk
|
|
11
|
+
# engine, push limit, Vishing Shield, case code, always biometrics).
|
|
12
|
+
# 2. wait_for_approval(request_id) (or poll status) until the phone answered.
|
|
13
|
+
# 3. Check safeTx["approval"] yourself with Touchque::SafeApproval.verify: it needs your own
|
|
14
|
+
# transaction and the approver's key from the guard, not this answer. Pass purpose:
|
|
15
|
+
# "schedule" for a governance change; without it only "approve" is accepted.
|
|
16
|
+
# 4. "purpose" "approve": SafeApproval.with_approvals(owner_signatures, approvals) as the
|
|
17
|
+
# signatures of execTransaction. "schedule": send SafeApproval.schedule_call(safe,
|
|
18
|
+
# governance_hash, approvals) to the guard; the change runs once its waiting time is over.
|
|
19
|
+
class SafeGuard
|
|
20
|
+
NUMBERS = %w[value safeTxGas baseGas gasPrice nonce].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(http)
|
|
23
|
+
@http = http
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Asks the approver's phone to approve a Safe transaction. +type+ is an action type from
|
|
27
|
+
# your dashboard; +safe_tx+ is { "chainId", "safe", "guard", "tx", "config" => { "threshold",
|
|
28
|
+
# "govThreshold", "govDelay", "exitDelay", "recipientLock" }, "batchTargets", "recipients",
|
|
29
|
+
# "approverIndex", "locale" }; +expires_in+ is seconds, 30 to 300 (default 300).
|
|
30
|
+
#
|
|
31
|
+
# tq.safe_guard.request("treasurer@acme.com", "safe_payment", {
|
|
32
|
+
# "chainId" => 1, "safe" => safe, "guard" => guard,
|
|
33
|
+
# "tx" => { "to" => to, "value" => 0, "data" => data, "operation" => 0, "nonce" => 42 },
|
|
34
|
+
# "config" => { "threshold" => 2, "govThreshold" => 3, "govDelay" => 172_800, "exitDelay" => 259_200 },
|
|
35
|
+
# "approverIndex" => 0
|
|
36
|
+
# })
|
|
37
|
+
def request(external_username, type, safe_tx, reference_id: nil, expires_in: nil, client_ip: nil, user_agent: nil)
|
|
38
|
+
raise ConfigError, "safe_guard.request needs an external_username" if external_username.to_s.empty?
|
|
39
|
+
raise ConfigError, "safe_guard.request needs a type: an action type from your dashboard" if type.to_s.empty?
|
|
40
|
+
|
|
41
|
+
body = { "externalUsername" => external_username, "type" => type, "safeTx" => wire(safe_tx) }
|
|
42
|
+
body.merge!(Util.compact({ "referenceId" => reference_id, "expiresIn" => expires_in, "clientIp" => client_ip, "userAgent" => user_agent }))
|
|
43
|
+
@http.post("/safe-guard/approvals", body)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The request's status and, once approved, the approval (safeTx["approval"], 105 bytes hex).
|
|
47
|
+
def status(request_id)
|
|
48
|
+
raise ConfigError, "safe_guard.status needs a request id" if request_id.to_s.empty?
|
|
49
|
+
|
|
50
|
+
@http.get("/safe-guard/approvals/#{Util.seg(request_id)}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Polls until the phone approved and returns the status with the approval. Raises
|
|
54
|
+
# RejectedError when the person said no, TimeoutError when it expired or +timeout+ seconds
|
|
55
|
+
# (default 5 minutes, the request's own life) ran out.
|
|
56
|
+
def wait_for_approval(request_id, timeout: 300, poll_interval: 1.5)
|
|
57
|
+
deadline = Time.now + timeout
|
|
58
|
+
loop do
|
|
59
|
+
s = status(request_id)
|
|
60
|
+
case s["status"]
|
|
61
|
+
when "CONFIRMED" then return s
|
|
62
|
+
when "REJECTED" then raise RejectedError.new(request_id)
|
|
63
|
+
when "EXPIRED" then raise TimeoutError.new("Touchque: request '#{request_id}' expired before it was approved.", request_id)
|
|
64
|
+
end
|
|
65
|
+
raise TimeoutError.new("Touchque: Safe Guard approval '#{request_id}' was not given within #{timeout}s.", request_id) if Time.now > deadline
|
|
66
|
+
|
|
67
|
+
sleep poll_interval
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# String keys, and large integers as decimal strings, as the API takes them.
|
|
74
|
+
def wire(safe_tx)
|
|
75
|
+
raise ConfigError, "safe_guard.request needs safe_tx: { \"chainId\", \"safe\", \"guard\", \"tx\", \"config\", \"approverIndex\" }" unless safe_tx.is_a?(Hash)
|
|
76
|
+
|
|
77
|
+
out = safe_tx.transform_keys(&:to_s)
|
|
78
|
+
tx = out["tx"]
|
|
79
|
+
raise ConfigError, "safe_guard.request needs safe_tx[\"tx\"]" unless tx.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
out["chainId"] = out["chainId"].to_s if out["chainId"].is_a?(Integer)
|
|
82
|
+
out["tx"] = tx.transform_keys(&:to_s).to_h { |k, v| [k, NUMBERS.include?(k) && v.is_a?(Integer) ? v.to_s : v] }
|
|
83
|
+
out
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|