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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +105 -0
  3. data/LICENSE +21 -0
  4. data/README.md +383 -0
  5. data/lib/touchque/agent.rb +211 -0
  6. data/lib/touchque/agent_card.rb +159 -0
  7. data/lib/touchque/approvals.rb +214 -0
  8. data/lib/touchque/client.rb +81 -0
  9. data/lib/touchque/code_reply.rb +53 -0
  10. data/lib/touchque/cross_app_access.rb +204 -0
  11. data/lib/touchque/crypto.rb +215 -0
  12. data/lib/touchque/errors.rb +246 -0
  13. data/lib/touchque/flow.rb +362 -0
  14. data/lib/touchque/hosted_login.rb +401 -0
  15. data/lib/touchque/http.rb +127 -0
  16. data/lib/touchque/login_session.rb +86 -0
  17. data/lib/touchque/org_agent.rb +398 -0
  18. data/lib/touchque/rack/relay.rb +75 -0
  19. data/lib/touchque/rails/guard.rb +132 -0
  20. data/lib/touchque/rails/hosted_login.rb +143 -0
  21. data/lib/touchque/rails/sessions_controller.rb +12 -0
  22. data/lib/touchque/railtie.rb +34 -0
  23. data/lib/touchque/relay.rb +533 -0
  24. data/lib/touchque/resources/actions.rb +212 -0
  25. data/lib/touchque/resources/agents.rb +251 -0
  26. data/lib/touchque/resources/device_risk.rb +130 -0
  27. data/lib/touchque/resources/identity.rb +331 -0
  28. data/lib/touchque/resources/login.rb +325 -0
  29. data/lib/touchque/resources/mobile.rb +115 -0
  30. data/lib/touchque/resources/people.rb +237 -0
  31. data/lib/touchque/resources/qr_pairings.rb +82 -0
  32. data/lib/touchque/resources/safe_guard.rb +87 -0
  33. data/lib/touchque/resources/webhook.rb +177 -0
  34. data/lib/touchque/safe_approval.rb +283 -0
  35. data/lib/touchque/security_events.rb +162 -0
  36. data/lib/touchque/util.rb +260 -0
  37. data/lib/touchque/version.rb +5 -0
  38. data/lib/touchque.rb +124 -0
  39. 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