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,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Touchque
|
|
4
|
+
# Storage for webhook jtis already accepted. +check_and_set+ must atomically record
|
|
5
|
+
# the jti for +ttl_seconds+ and return true when it was NOT seen before. Use
|
|
6
|
+
# MemoryReplayCache in one process, or one over Redis (SET key 1 NX EX ttl) or your
|
|
7
|
+
# database when you run several (Puma clusters, several hosts).
|
|
8
|
+
class MemoryReplayCache
|
|
9
|
+
def initialize(max_entries: 100_000)
|
|
10
|
+
@max_entries = [max_entries, 2].max
|
|
11
|
+
@seen = {}
|
|
12
|
+
@lock = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check_and_set(jti, ttl_seconds)
|
|
16
|
+
@lock.synchronize do
|
|
17
|
+
now = Time.now.to_f
|
|
18
|
+
return false if @seen[jti] && @seen[jti] > now
|
|
19
|
+
|
|
20
|
+
if @seen.size >= @max_entries
|
|
21
|
+
@seen.delete_if { |_, exp| exp <= now }
|
|
22
|
+
@seen.shift while @seen.size >= @max_entries
|
|
23
|
+
end
|
|
24
|
+
@seen[jti] = now + ttl_seconds
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
module Resources
|
|
31
|
+
# Webhook signature verification (signed with your API secret).
|
|
32
|
+
class Webhook
|
|
33
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
34
|
+
|
|
35
|
+
# "reason" values of a REJECTED login webhook. Treat a value you do not know as a rejection.
|
|
36
|
+
REASON_USER_REJECTED = "user_rejected"
|
|
37
|
+
# The user said "this wasn't me": 2FA is frozen.
|
|
38
|
+
REASON_USER_REPORTED_FRAUD = "user_reported_fraud"
|
|
39
|
+
# The user said someone on the phone told them to approve: 2FA is frozen; treat it as a takeover in progress.
|
|
40
|
+
REASON_USER_REPORTED_COACHED = "user_reported_coached"
|
|
41
|
+
# An AI agent's request: the user said no and blocked the agent from asking them again (no 2FA freeze).
|
|
42
|
+
REASON_USER_BLOCKED_AGENT = "user_blocked_agent"
|
|
43
|
+
REASON_RISK_REVERSED = "risk_reversed"
|
|
44
|
+
REASON_RISK_STEP_UP = "risk_step_up"
|
|
45
|
+
REASON_APPROVAL_SIGNATURE_INVALID = "approval_signature_invalid"
|
|
46
|
+
REASON_DEVICE_INTEGRITY_FAILED = "device_integrity_failed"
|
|
47
|
+
# Vishing Shield: approved during a call (or with the screen shared / remote-controlled), critical, no passkey.
|
|
48
|
+
REASON_VISHING_SUSPECTED = "vishing_suspected"
|
|
49
|
+
# Mirror Alarm: the sign-in page was not on your site (a real-time phishing proxy).
|
|
50
|
+
REASON_PHISHING_ORIGIN = "phishing_origin"
|
|
51
|
+
|
|
52
|
+
# "event" values of event webhooks. Ignore types you do not know.
|
|
53
|
+
EVENT_DEVICE_LINKED = "DEVICE_LINKED"
|
|
54
|
+
EVENT_DEVICE_UNLINKED = "DEVICE_UNLINKED"
|
|
55
|
+
EVENT_PASSKEY_REGISTERED = "PASSKEY_REGISTERED"
|
|
56
|
+
EVENT_PASSKEY_DELETED = "PASSKEY_DELETED"
|
|
57
|
+
# Device risk: an identification or a device_risk.check raised a signal that asks for more. Never a trait.
|
|
58
|
+
EVENT_DEVICE_RISK_SIGNAL = "DEVICE_RISK_SIGNAL"
|
|
59
|
+
# Device risk: an alert rule of the workspace went off.
|
|
60
|
+
EVENT_DEVICE_RISK_ALERT = "DEVICE_RISK_ALERT"
|
|
61
|
+
# After the call, the user confirmed a provisional approval was theirs: critical actions are allowed again.
|
|
62
|
+
EVENT_APPROVAL_RECONFIRMED = "APPROVAL_RECONFIRMED"
|
|
63
|
+
# The user took an approval back (reason COACHED or UNDO): end the session built on it.
|
|
64
|
+
EVENT_APPROVAL_REVOKED = "APPROVAL_REVOKED"
|
|
65
|
+
# Mobile SDK: send this push with your own keys (Mobile.push_from_webhook).
|
|
66
|
+
EVENT_MOBILE_PUSH = "MOBILE_PUSH"
|
|
67
|
+
EVENT_AGENT_CREATED = "AGENT_CREATED"
|
|
68
|
+
EVENT_AGENT_SUSPENDED = "AGENT_SUSPENDED"
|
|
69
|
+
EVENT_AGENT_RESUMED = "AGENT_RESUMED"
|
|
70
|
+
EVENT_AGENT_REVOKED = "AGENT_REVOKED"
|
|
71
|
+
EVENT_AGENT_KEY_ADDED = "AGENT_KEY_ADDED"
|
|
72
|
+
EVENT_AGENT_KEY_REMOVED = "AGENT_KEY_REMOVED"
|
|
73
|
+
# The kill switch stopped every AI agent of the workspace.
|
|
74
|
+
EVENT_AGENTS_PAUSED = "AGENTS_PAUSED"
|
|
75
|
+
EVENT_AGENTS_RESUMED = "AGENTS_RESUMED"
|
|
76
|
+
EVENT_AGENT_POLICY_CHANGED = "AGENT_POLICY_CHANGED"
|
|
77
|
+
EVENT_AGENT_WORKSPACE_POLICY_CHANGED = "AGENT_WORKSPACE_POLICY_CHANGED"
|
|
78
|
+
EVENT_AGENT_APPROVAL_REDEEMED = "AGENT_APPROVAL_REDEEMED"
|
|
79
|
+
# A person stopped an AI agent from asking them ("this wasn't me", coached, or three rejections).
|
|
80
|
+
EVENT_AGENT_BLOCKED_BY_PERSON = "AGENT_BLOCKED_BY_PERSON"
|
|
81
|
+
EVENT_AGENT_UNBLOCKED_FOR_PERSON = "AGENT_UNBLOCKED_FOR_PERSON"
|
|
82
|
+
# An AI agent sent the same request to the same person three times in 10 minutes.
|
|
83
|
+
EVENT_AGENT_LOOP_DETECTED = "AGENT_LOOP_DETECTED"
|
|
84
|
+
EVENT_AGENT_MANDATE_REQUESTED = "AGENT_MANDATE_REQUESTED"
|
|
85
|
+
EVENT_AGENT_MANDATE_REVOKED = "AGENT_MANDATE_REVOKED"
|
|
86
|
+
# An AI agent's text carried an instruction to the model or a link that carries data; the request was refused.
|
|
87
|
+
EVENT_AGENT_INJECTION_SUSPECTED = "AGENT_INJECTION_SUSPECTED"
|
|
88
|
+
# An approval came back sooner than a person can answer; it is never usable.
|
|
89
|
+
EVENT_AGENT_INHUMAN_APPROVAL = "AGENT_INHUMAN_APPROVAL"
|
|
90
|
+
# A new or renamed AI agent's name read like another agent's; it was refused.
|
|
91
|
+
EVENT_AGENT_LOOKALIKE_NAME = "AGENT_LOOKALIKE_NAME"
|
|
92
|
+
# Never Locked Out (tq.recovery): every step of a recovery, with "recoveryId" and "status".
|
|
93
|
+
# RECOVERY_COMPLETED is followed by DEVICE_LINKED ("via" => "recovery"); VETOED, EXPIRED
|
|
94
|
+
# and FAILED end it without a move.
|
|
95
|
+
EVENT_RECOVERY_STARTED = "RECOVERY_STARTED"
|
|
96
|
+
EVENT_RECOVERY_DEVICE_BOUND = "RECOVERY_DEVICE_BOUND"
|
|
97
|
+
# The vouchers' quorum was reached: the account moves at "coolOffUntil" unless vetoed.
|
|
98
|
+
EVENT_RECOVERY_COOLING = "RECOVERY_COOLING"
|
|
99
|
+
EVENT_RECOVERY_COMPLETED = "RECOVERY_COMPLETED"
|
|
100
|
+
EVENT_RECOVERY_VETOED = "RECOVERY_VETOED"
|
|
101
|
+
EVENT_RECOVERY_EXPIRED = "RECOVERY_EXPIRED"
|
|
102
|
+
EVENT_RECOVERY_FAILED = "RECOVERY_FAILED"
|
|
103
|
+
# Help desk (tq.assist): a session opened or ended, a check on the phone passed or failed
|
|
104
|
+
# ("outcome": wrong_number, not_me, coached, declined), approvals unfrozen after a check.
|
|
105
|
+
EVENT_ASSIST_OPENED = "ASSIST_OPENED"
|
|
106
|
+
EVENT_ASSIST_ENDED = "ASSIST_ENDED"
|
|
107
|
+
EVENT_ASSIST_CHECK_PASSED = "ASSIST_CHECK_PASSED"
|
|
108
|
+
EVENT_ASSIST_CHECK_FAILED = "ASSIST_CHECK_FAILED"
|
|
109
|
+
EVENT_ASSIST_UNFROZEN = "ASSIST_UNFROZEN"
|
|
110
|
+
# The person checked that the agent from IT is really working on their account.
|
|
111
|
+
EVENT_ASSIST_CLAIM_CONFIRMED = "ASSIST_CLAIM_CONFIRMED"
|
|
112
|
+
# Nobody from IT was working on the account when the person asked: someone is pretending to be IT.
|
|
113
|
+
EVENT_ASSIST_CLAIM_UNVERIFIED = "ASSIST_CLAIM_UNVERIFIED"
|
|
114
|
+
|
|
115
|
+
def initialize(api_secret)
|
|
116
|
+
@secret = api_secret
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Never shows the API secret (logs, consoles, error reporters).
|
|
120
|
+
def inspect
|
|
121
|
+
"#<#{self.class.name}>"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Verifies an incoming webhook and returns its payload.
|
|
125
|
+
#
|
|
126
|
+
# raw_body: the request body exactly as received. signature: the x-signature header
|
|
127
|
+
# (nil uses the "signature" field inside the body). tolerance_seconds: reject a
|
|
128
|
+
# callback whose signed timestamp is missing, invalid or further than this from now
|
|
129
|
+
# (0 disables it). replay_cache: a second delivery of the same jti raises
|
|
130
|
+
# WebhookReplayError (answer 200, don't run your side effects again).
|
|
131
|
+
def verify(raw_body, signature = nil, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, replay_cache: nil)
|
|
132
|
+
payload = Util.parse_json(raw_body.to_s)
|
|
133
|
+
raise WebhookSignatureError, "invalid JSON payload" unless payload.is_a?(Hash)
|
|
134
|
+
|
|
135
|
+
provided = signature.to_s.empty? ? payload["signature"] : signature
|
|
136
|
+
raise WebhookSignatureError, "no signature provided" unless provided.is_a?(String) && !provided.empty?
|
|
137
|
+
|
|
138
|
+
begin
|
|
139
|
+
canonical = self.class.canonicalize(raw_body.to_s)
|
|
140
|
+
rescue JSON::ParserError
|
|
141
|
+
raise WebhookSignatureError, "could not canonicalize the payload"
|
|
142
|
+
end
|
|
143
|
+
# Never put the expected signature in the error: it would be a signing oracle.
|
|
144
|
+
raise WebhookSignatureError, "invalid signature" unless Util.secure_compare(Util.hmac_hex(@secret, canonical), provided)
|
|
145
|
+
|
|
146
|
+
# Touchque always signs a timestamp: a missing or unparseable one fails closed.
|
|
147
|
+
if tolerance_seconds.positive?
|
|
148
|
+
at = Util.parse_time(payload["timestamp"])
|
|
149
|
+
raise WebhookSignatureError, "timestamp is missing or invalid" if at.nil?
|
|
150
|
+
raise WebhookSignatureError, "timestamp is outside the allowed window" if (Time.now.to_f - at).abs > tolerance_seconds
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if replay_cache
|
|
154
|
+
jti = payload["jti"]
|
|
155
|
+
raise WebhookSignatureError, "jti is missing" unless jti.is_a?(String) && !jti.empty?
|
|
156
|
+
# Remember it for twice the window so it outlives any timestamp that could still pass.
|
|
157
|
+
raise WebhookReplayError.new(jti) unless replay_cache.check_and_set(jti, [tolerance_seconds * 2, 600].max)
|
|
158
|
+
end
|
|
159
|
+
payload
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The server's stableStringify: drop "signature", sort the TOP-LEVEL keys only (by
|
|
163
|
+
# UTF-16 code units, like JavaScript), keep each value's bytes (nested key order,
|
|
164
|
+
# number spelling); insignificant whitespace is removed.
|
|
165
|
+
def self.canonicalize(raw_body)
|
|
166
|
+
raise JSON::ParserError, "not an object" unless JSON.parse(raw_body).is_a?(Hash)
|
|
167
|
+
|
|
168
|
+
members = {}
|
|
169
|
+
Util.top_level_members(raw_body).each { |key, value| members[key] = value unless key == "signature" }
|
|
170
|
+
body = members.keys.sort_by { |k| k.encode("UTF-16BE").bytes }.map do |key|
|
|
171
|
+
"#{Util.js_string(key)}:#{Util.compact_json(members[key])}"
|
|
172
|
+
end
|
|
173
|
+
"{#{body.join(",")}}"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Touchque
|
|
6
|
+
# Safe Guard approvals, checked without trusting Touchque. The approver's phone decodes the
|
|
7
|
+
# raw Safe transaction itself and signs, with a P-256 key only its biometrics unlock, a
|
|
8
|
+
# 105-byte approval that TouchqueSafeGuard checks on chain:
|
|
9
|
+
#
|
|
10
|
+
# index (1) | expiresAt (8) | displayDigest (32) | r (32) | s (32)
|
|
11
|
+
#
|
|
12
|
+
# over the SHA-256 of the 149-byte message
|
|
13
|
+
#
|
|
14
|
+
# "TQG1" | purpose (1) | chainId (32) | guard (20) | Safe (20) | subject (32) | expiresAt (8) | displayDigest (32)
|
|
15
|
+
#
|
|
16
|
+
# where subject is the Safe transaction hash (EIP-712, purpose 1 "approve") or, for a change
|
|
17
|
+
# the guard schedules, the governance hash (purpose 2 "schedule"). verify makes the guard's
|
|
18
|
+
# check off chain: both hashes are computed here from the raw fields, so a co-signer (or your
|
|
19
|
+
# own server) refuses an approval of anything else, whoever relayed it.
|
|
20
|
+
#
|
|
21
|
+
# A target is { "chainId", "safe", "guard", "tx" => { "to", "value", "data", "operation",
|
|
22
|
+
# "safeTxGas", "baseGas", "gasPrice", "gasToken", "refundReceiver", "nonce" } } (string or
|
|
23
|
+
# symbol keys); integers as Integer or decimal strings.
|
|
24
|
+
module SafeApproval
|
|
25
|
+
MAGIC = "TQG1".b
|
|
26
|
+
PURPOSES = { "approve" => 1, "schedule" => 2 }.freeze
|
|
27
|
+
# The guard (MAX_APPROVAL_TTL) refuses an approval valid for longer than a day.
|
|
28
|
+
MAX_APPROVAL_TTL = 86_400
|
|
29
|
+
# OpenZeppelin P256 (the guard) refuses s above N/2.
|
|
30
|
+
HALF_N = 0x7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8
|
|
31
|
+
MAX_UINT256 = (1 << 256) - 1
|
|
32
|
+
MAX_UINT64 = (1 << 64) - 1
|
|
33
|
+
ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
|
|
34
|
+
# SubjectPublicKeyInfo prefix of an uncompressed P-256 point.
|
|
35
|
+
SPKI_P256 = ["3059301306072a8648ce3d020106082a8648ce3d030107034200"].pack("H*")
|
|
36
|
+
MASK = (1 << 64) - 1
|
|
37
|
+
RC = %w[
|
|
38
|
+
0000000000000001 0000000000008082 800000000000808a 8000000080008000 000000000000808b 0000000080000001
|
|
39
|
+
8000000080008081 8000000000008009 000000000000008a 0000000000000088 0000000080008009 000000008000000a
|
|
40
|
+
000000008000808b 800000000000008b 8000000000008089 8000000000008003 8000000000008002 8000000000000080
|
|
41
|
+
000000000000800a 800000008000000a 8000000080008081 8000000000008080 0000000080000001 8000000080008008
|
|
42
|
+
].map { |h| h.to_i(16) }.freeze
|
|
43
|
+
# Rotation of lane x + 5y.
|
|
44
|
+
ROT = [0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14].freeze
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
# The Safe transaction hash (EIP-712, domain = chainId + the Safe): what the owners sign and
|
|
49
|
+
# an ordinary approval covers.
|
|
50
|
+
def safe_tx_hash(target)
|
|
51
|
+
t = parse(target)
|
|
52
|
+
domain = keccak256(TYPE_HASHES[:domain] + t[:chain_id] + t[:safe])
|
|
53
|
+
struct = keccak256(
|
|
54
|
+
TYPE_HASHES[:safe_tx] + t[:to] + t[:value] + keccak256(t[:data]) + t[:operation] + t[:safe_tx_gas] +
|
|
55
|
+
t[:base_gas] + t[:gas_price] + t[:gas_token] + t[:refund_receiver] + t[:nonce]
|
|
56
|
+
)
|
|
57
|
+
hex(keccak256("\x19\x01".b + domain + struct))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# What the guard schedules a governance change under: the change itself, without the nonce
|
|
61
|
+
# or gas fields.
|
|
62
|
+
def governance_hash(target)
|
|
63
|
+
t = parse(target)
|
|
64
|
+
hex(keccak256(TYPE_HASHES[:governance] + t[:chain_id] + t[:safe] + t[:to] + t[:value] + keccak256(t[:data]) + t[:operation]))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The 149-byte message the phone signs (its SHA-256 is what the guard verifies), as 0x hex.
|
|
68
|
+
# +purpose+ is "approve" or "schedule"; +expires_at+ Unix seconds.
|
|
69
|
+
def message(purpose:, chain_id:, guard:, safe:, subject:, expires_at:, display_digest:)
|
|
70
|
+
code = PURPOSES[purpose.to_s] or raise ConfigError, "SafeApproval: purpose is \"approve\" or \"schedule\""
|
|
71
|
+
hex(
|
|
72
|
+
MAGIC + code.chr + uint(chain_id, "chainId", MAX_UINT64) + address(guard, "guard")[12, 20] + address(safe, "safe")[12, 20] +
|
|
73
|
+
bytes(subject, "subject", 32) + uint(expires_at, "expiresAt", MAX_UINT64)[24, 8] + bytes(display_digest, "displayDigest", 32)
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# SHA-256 of the rows as "label\x1Fvalue" joined by "\x1E" (UTF-8), as 0x hex: the digest an
|
|
78
|
+
# approval carries. Rows are { "label", "value" } (string or symbol keys).
|
|
79
|
+
def display_digest(rows)
|
|
80
|
+
canonical = rows.map { |r| "#{field(r, "label")}\u001F#{field(r, "value")}" }.join("\u001E")
|
|
81
|
+
"0x#{OpenSSL::Digest::SHA256.hexdigest(canonical.encode("UTF-8"))}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Checks a phone's Safe Guard approval the way TouchqueSafeGuard does, without trusting the
|
|
85
|
+
# server that relayed it: the Safe and governance hashes come from +target+, the key from
|
|
86
|
+
# the guard's approver list on chain ("x || y", 128 hex characters, or { "x", "y" }).
|
|
87
|
+
# +purpose+: what you are about to use the approval for, "approve" (the default: run this
|
|
88
|
+
# very Safe transaction) or "schedule" (start this governance change's waiting time). A
|
|
89
|
+
# schedule approval covers no nonce or gas fields, so it is accepted only when you name
|
|
90
|
+
# "schedule". +approver_index+: the approval must name it.
|
|
91
|
+
# +display_digest+ or +rows+: from your own decoding, the rows the phone must have shown.
|
|
92
|
+
# +now+: a Time or Unix seconds.
|
|
93
|
+
#
|
|
94
|
+
# Returns { "valid", "reason", "purpose", "approverIndex", "expiresAt", "displayDigest",
|
|
95
|
+
# "safeTxHash", "governanceHash" }; "reason" is nil when valid, else approval_malformed,
|
|
96
|
+
# public_key_invalid, approver_index_mismatch, approval_expired, rows_differ, high_s or
|
|
97
|
+
# signature_invalid. Raises ConfigError only when +target+ is not a Safe transaction.
|
|
98
|
+
#
|
|
99
|
+
# status = tq.safe_guard.status(request_id)
|
|
100
|
+
# check = Touchque::SafeApproval.verify(my_tx, status["safeTx"]["approval"], approver_key_from_chain, approver_index: 0)
|
|
101
|
+
# raise "approval refused: #{check["reason"]}" unless check["valid"]
|
|
102
|
+
def verify(target, approval, public_key, purpose: nil, approver_index: nil, display_digest: nil, rows: nil, now: nil)
|
|
103
|
+
t = parse(target)
|
|
104
|
+
hashes = { "safeTxHash" => safe_tx_hash(target), "governanceHash" => governance_hash(target) }
|
|
105
|
+
parsed = { "approverIndex" => nil, "expiresAt" => nil, "displayDigest" => nil }
|
|
106
|
+
refuse = ->(reason) { { "valid" => false, "reason" => reason, "purpose" => nil }.merge(parsed, hashes) }
|
|
107
|
+
return refuse.call("approval_malformed") unless approval.is_a?(String) && approval.match?(/\A(0x)?[0-9a-fA-F]{210}\z/)
|
|
108
|
+
|
|
109
|
+
b = [strip0x(approval)].pack("H*")
|
|
110
|
+
expires_at = b[1, 8].unpack1("Q>")
|
|
111
|
+
parsed = { "approverIndex" => b.getbyte(0), "expiresAt" => expires_at, "displayDigest" => hex(b[9, 32]) }
|
|
112
|
+
key = ec_key(public_key)
|
|
113
|
+
return refuse.call("public_key_invalid") if key.nil?
|
|
114
|
+
return refuse.call("approver_index_mismatch") if !approver_index.nil? && approver_index != parsed["approverIndex"]
|
|
115
|
+
|
|
116
|
+
seconds = now.nil? ? Time.now.to_i : now.to_i
|
|
117
|
+
return refuse.call("approval_expired") if expires_at < seconds || expires_at > seconds + MAX_APPROVAL_TTL
|
|
118
|
+
|
|
119
|
+
expected = display_digest || (rows && SafeApproval.display_digest(rows))
|
|
120
|
+
return refuse.call("rows_differ") if expected && "0x#{strip0x(expected.downcase)}" != parsed["displayDigest"]
|
|
121
|
+
|
|
122
|
+
r = b[41, 32].unpack1("H*").to_i(16)
|
|
123
|
+
s = b[73, 32].unpack1("H*").to_i(16)
|
|
124
|
+
return refuse.call("high_s") if s > HALF_N
|
|
125
|
+
|
|
126
|
+
der = OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(OpenSSL::BN.new(r)), OpenSSL::ASN1::Integer(OpenSSL::BN.new(s))]).to_der
|
|
127
|
+
# Never falls back to "schedule": that one is valid at any nonce.
|
|
128
|
+
p = (purpose || "approve").to_s
|
|
129
|
+
msg = message(
|
|
130
|
+
purpose: p, chain_id: t[:chain_id].unpack1("H*").to_i(16), guard: hex(t[:guard][12, 20]), safe: hex(t[:safe][12, 20]),
|
|
131
|
+
subject: p == "approve" ? hashes["safeTxHash"] : hashes["governanceHash"], expires_at: expires_at, display_digest: parsed["displayDigest"]
|
|
132
|
+
)
|
|
133
|
+
ok = begin
|
|
134
|
+
key.verify("SHA256", der, [strip0x(msg)].pack("H*"))
|
|
135
|
+
rescue OpenSSL::PKey::PKeyError
|
|
136
|
+
false
|
|
137
|
+
end
|
|
138
|
+
return refuse.call("signature_invalid") unless ok
|
|
139
|
+
|
|
140
|
+
{ "valid" => true, "reason" => nil, "purpose" => p }.merge(parsed, hashes)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The owners' Safe signatures with the approvals appended (approvals by index | count |
|
|
144
|
+
# "TQG1"): the signatures argument of execTransaction for an ordinary transaction.
|
|
145
|
+
def with_approvals(owner_signatures, approvals)
|
|
146
|
+
items = approval_bytes(approvals)
|
|
147
|
+
hex(bytes(owner_signatures, "ownerSignatures") + items.join + items.size.chr + MAGIC)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The call data of guard.schedule(safe, governanceHash, approvals), which starts a
|
|
151
|
+
# governance change's waiting time.
|
|
152
|
+
def schedule_call(safe, governance_hash, approvals)
|
|
153
|
+
joined = approval_bytes(approvals).join
|
|
154
|
+
hex(
|
|
155
|
+
TYPE_HASHES[:schedule][0, 4] + address(safe, "safe") + bytes(governance_hash, "governanceHash", 32) + uint(96, "offset", MAX_UINT256) +
|
|
156
|
+
uint(joined.bytesize, "length", MAX_UINT256) + joined + ("\0".b * ((32 - (joined.bytesize % 32)) % 32))
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# ── Keccak-256 (the Ethereum hash: Keccak padding, not SHA3-256) ──
|
|
161
|
+
|
|
162
|
+
def rotl(value, n)
|
|
163
|
+
n.zero? ? value : (((value << n) | (value >> (64 - n))) & MASK)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def keccak_f(a)
|
|
167
|
+
RC.each do |rc|
|
|
168
|
+
c = (0...5).map { |x| a[x] ^ a[x + 5] ^ a[x + 10] ^ a[x + 15] ^ a[x + 20] }
|
|
169
|
+
5.times do |x|
|
|
170
|
+
d = c[(x + 4) % 5] ^ rotl(c[(x + 1) % 5], 1)
|
|
171
|
+
0.step(20, 5) { |y| a[x + y] ^= d }
|
|
172
|
+
end
|
|
173
|
+
b = Array.new(25, 0)
|
|
174
|
+
5.times { |x| 5.times { |y| b[y + (5 * (((2 * x) + (3 * y)) % 5))] = rotl(a[x + (5 * y)], ROT[x + (5 * y)]) } }
|
|
175
|
+
0.step(20, 5) { |y| 5.times { |x| a[x + y] = b[x + y] ^ (~b[((x + 1) % 5) + y] & b[((x + 2) % 5) + y] & MASK) } }
|
|
176
|
+
a[0] ^= rc
|
|
177
|
+
end
|
|
178
|
+
a
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def keccak256(data)
|
|
182
|
+
rate = 136
|
|
183
|
+
padded = data.b + ("\0".b * (rate - (data.bytesize % rate)))
|
|
184
|
+
padded.setbyte(data.bytesize, padded.getbyte(data.bytesize) ^ 0x01)
|
|
185
|
+
padded.setbyte(padded.bytesize - 1, padded.getbyte(padded.bytesize - 1) ^ 0x80)
|
|
186
|
+
a = Array.new(25, 0)
|
|
187
|
+
(0...padded.bytesize).step(rate) do |off|
|
|
188
|
+
padded[off, rate].unpack("Q<17").each_with_index { |lane, k| a[k] ^= lane }
|
|
189
|
+
keccak_f(a)
|
|
190
|
+
end
|
|
191
|
+
a[0, 4].pack("Q<4")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
TYPE_HASHES = {
|
|
195
|
+
domain: keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"),
|
|
196
|
+
safe_tx: keccak256("SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)"),
|
|
197
|
+
# The guard contract hashes this exact string, so it keeps its original spelling.
|
|
198
|
+
governance: keccak256("TouchQueGovernance(uint256 chainId,address safe,address to,uint256 value,bytes32 dataHash,uint8 operation)"),
|
|
199
|
+
schedule: keccak256("schedule(address,bytes32,bytes)")
|
|
200
|
+
}.freeze
|
|
201
|
+
|
|
202
|
+
# ── Field parsing: the partner's own data, so a wrong field raises ──
|
|
203
|
+
|
|
204
|
+
def field(hash, key)
|
|
205
|
+
hash.key?(key) ? hash[key] : hash[key.to_sym]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def parse(target)
|
|
209
|
+
tx = target.is_a?(Hash) ? field(target, "tx") : nil
|
|
210
|
+
raise ConfigError, "SafeApproval: pass { \"chainId\", \"safe\", \"guard\", \"tx\" => { \"to\", \"value\", \"data\", \"operation\", ..., \"nonce\" } }" unless tx.is_a?(Hash)
|
|
211
|
+
|
|
212
|
+
operation = field(tx, "operation") || 0
|
|
213
|
+
raise ConfigError, "SafeApproval: tx.operation must be 0 (call) or 1 (delegatecall)" unless [0, 1].include?(operation)
|
|
214
|
+
|
|
215
|
+
{
|
|
216
|
+
chain_id: uint(field(target, "chainId"), "chainId", MAX_UINT64),
|
|
217
|
+
safe: address(field(target, "safe"), "safe"),
|
|
218
|
+
guard: address(field(target, "guard"), "guard"),
|
|
219
|
+
to: address(field(tx, "to"), "tx.to"),
|
|
220
|
+
value: uint(field(tx, "value") || 0, "tx.value", MAX_UINT256),
|
|
221
|
+
data: bytes(field(tx, "data") || "0x", "tx.data"),
|
|
222
|
+
operation: uint(operation, "tx.operation", 1),
|
|
223
|
+
safe_tx_gas: uint(field(tx, "safeTxGas") || 0, "tx.safeTxGas", MAX_UINT256),
|
|
224
|
+
base_gas: uint(field(tx, "baseGas") || 0, "tx.baseGas", MAX_UINT256),
|
|
225
|
+
gas_price: uint(field(tx, "gasPrice") || 0, "tx.gasPrice", MAX_UINT256),
|
|
226
|
+
gas_token: address(field(tx, "gasToken") || ZERO_ADDRESS, "tx.gasToken"),
|
|
227
|
+
refund_receiver: address(field(tx, "refundReceiver") || ZERO_ADDRESS, "tx.refundReceiver"),
|
|
228
|
+
nonce: uint(field(tx, "nonce"), "tx.nonce", MAX_UINT256)
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# A non-negative integer (Integer or decimal string) as a 32-byte word.
|
|
233
|
+
def uint(value, name, max)
|
|
234
|
+
v = if value.is_a?(Integer) then value
|
|
235
|
+
elsif value.is_a?(String) && value.match?(/\A(0|[1-9][0-9]*)\z/) then value.to_i
|
|
236
|
+
end
|
|
237
|
+
raise ConfigError, "SafeApproval: #{name} must be a non-negative integer (a decimal string for large values)" if v.nil? || v.negative? || v > max
|
|
238
|
+
|
|
239
|
+
[v.to_s(16).rjust(64, "0")].pack("H*")
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def address(value, name)
|
|
243
|
+
raise ConfigError, "SafeApproval: #{name} must be an address (0x and 40 hex characters)" unless value.is_a?(String) && value.match?(/\A0x[0-9a-fA-F]{40}\z/)
|
|
244
|
+
|
|
245
|
+
("\0".b * 12) + [value[2..]].pack("H*")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def bytes(value, name, size = nil)
|
|
249
|
+
unless value.is_a?(String) && value.match?(/\A0x(?:[0-9a-fA-F]{2})*\z/) && (size.nil? || value.length == 2 + (2 * size))
|
|
250
|
+
raise ConfigError, "SafeApproval: #{name} must be 0x-prefixed hex#{size ? " of #{size} bytes" : ""}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
[value[2..]].pack("H*")
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def approval_bytes(approvals)
|
|
257
|
+
list = Array(approvals)
|
|
258
|
+
raise ConfigError, "SafeApproval: pass 1 to 255 approvals" unless list.size.between?(1, 255)
|
|
259
|
+
|
|
260
|
+
list.each_with_index.map { |a, i| bytes(a.to_s.start_with?("0x") ? a : "0x#{a}", "approvals[#{i}]", 105) }.sort_by.with_index { |a, i| [a.getbyte(0), i] }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def ec_key(key)
|
|
264
|
+
xy = key.is_a?(Hash) ? strip0x(field(key, "x").to_s) + strip0x(field(key, "y").to_s) : strip0x(key.to_s)
|
|
265
|
+
return nil unless xy.match?(/\A[0-9a-fA-F]{128}\z/)
|
|
266
|
+
|
|
267
|
+
ec = OpenSSL::PKey.read(SPKI_P256 + ["04#{xy}"].pack("H*"))
|
|
268
|
+
ec.public_key.on_curve? ? ec : nil
|
|
269
|
+
rescue OpenSSL::PKey::PKeyError, OpenSSL::PKey::EC::Point::Error
|
|
270
|
+
nil
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def strip0x(text)
|
|
274
|
+
text.start_with?("0x") ? text[2..] : text
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def hex(data)
|
|
278
|
+
"0x#{data.unpack1("H*")}"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
private_class_method :rotl, :keccak_f, :keccak256, :field, :parse, :uint, :address, :bytes, :approval_bytes, :ec_key, :strip0x, :hex
|
|
282
|
+
end
|
|
283
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Touchque
|
|
4
|
+
# A refused Security Event Token (Shared Signals). +error+ is the RFC 8935 error
|
|
5
|
+
# to answer with (HTTP 400 {"err": error}): invalid_request, invalid_key,
|
|
6
|
+
# invalid_issuer or invalid_audience; temporarily_unavailable when the issuer's
|
|
7
|
+
# keys could not be read (answer 503: the transmitter tries again).
|
|
8
|
+
class SecurityEventError < Error
|
|
9
|
+
attr_reader :error
|
|
10
|
+
|
|
11
|
+
def initialize(error, message)
|
|
12
|
+
@error = error
|
|
13
|
+
super(message)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Shared Signals (OpenID Shared Signals Framework 1.0): Security Event Tokens
|
|
18
|
+
# (SETs, RFC 8417) an identity provider sends when something happens to a
|
|
19
|
+
# person's account after sign-in: CAEP session-revoked, credential-change,
|
|
20
|
+
# device-compliance-change; RISC account-disabled, credential-compromise.
|
|
21
|
+
# Touchque SSO sends them for an organization's members to the receivers its
|
|
22
|
+
# admin adds (Organization > Continuous access). Your app ends the person's
|
|
23
|
+
# sessions or asks them to sign in again.
|
|
24
|
+
#
|
|
25
|
+
# Push delivery (RFC 8935), the SET is the request body (application/secevent+jwt):
|
|
26
|
+
#
|
|
27
|
+
# VERIFIER = Touchque::SecurityEventVerifier.new(issuer: "https://sso.touchque.com", audience: "https://app.example.com")
|
|
28
|
+
# post "/ssf" do
|
|
29
|
+
# event = VERIFIER.verify(request.body.read)
|
|
30
|
+
# end_sessions_of(event["subject"]["email"]) if event["name"] == "session-revoked"
|
|
31
|
+
# status 202
|
|
32
|
+
# rescue Touchque::SecurityEventError => e
|
|
33
|
+
# halt 400, { err: e.error, description: e.message }.to_json
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# Poll delivery (RFC 8936): Touchque::SecurityEvents.poll(url:, token:, ack: [...]).
|
|
37
|
+
#
|
|
38
|
+
# A SET does not expire: it says what happened. Keep the jtis you handled and
|
|
39
|
+
# skip a repeat.
|
|
40
|
+
module SecurityEvents
|
|
41
|
+
# The event types Touchque sends, by their short name.
|
|
42
|
+
TYPES = {
|
|
43
|
+
"session-revoked" => "https://schemas.openid.net/secevent/caep/event-type/session-revoked",
|
|
44
|
+
"credential-change" => "https://schemas.openid.net/secevent/caep/event-type/credential-change",
|
|
45
|
+
"device-compliance-change" => "https://schemas.openid.net/secevent/caep/event-type/device-compliance-change",
|
|
46
|
+
"account-disabled" => "https://schemas.openid.net/secevent/risc/event-type/account-disabled",
|
|
47
|
+
"credential-compromise" => "https://schemas.openid.net/secevent/risc/event-type/credential-compromise",
|
|
48
|
+
"verification" => "https://schemas.openid.net/secevent/ssf/event-type/verification",
|
|
49
|
+
"stream-updated" => "https://schemas.openid.net/secevent/ssf/event-type/stream-updated"
|
|
50
|
+
}.freeze
|
|
51
|
+
NAMESPACE = %r{\Ahttps://schemas\.openid\.net/secevent/(caep|risc|ssf)/event-type/([a-z0-9-]+)\z}.freeze
|
|
52
|
+
|
|
53
|
+
module_function
|
|
54
|
+
|
|
55
|
+
# One poll: acknowledges what you handled and returns { "sets" => { jti => token }, "more_available" => bool }.
|
|
56
|
+
def poll(url:, token:, ack: nil, set_errs: nil, max_events: nil, return_immediately: nil)
|
|
57
|
+
raise ConfigError, "#{url} must be an https URL" unless CrossAppAccess.safe_url?(url)
|
|
58
|
+
raise ConfigError, "poll needs the stream's token" if token.to_s.empty?
|
|
59
|
+
|
|
60
|
+
body = {}
|
|
61
|
+
body["ack"] = ack unless ack.nil?
|
|
62
|
+
body["setErrs"] = set_errs unless set_errs.nil?
|
|
63
|
+
body["maxEvents"] = max_events unless max_events.nil?
|
|
64
|
+
body["returnImmediately"] = return_immediately unless return_immediately.nil?
|
|
65
|
+
headers = { "Content-Type" => "application/json", "Accept" => "application/json", "Authorization" => "Bearer #{token}" }
|
|
66
|
+
status, text, = Transport.call("POST", url, headers: headers, body: JSON.generate(body), timeout: 30)
|
|
67
|
+
data = Util.parse_json(text)
|
|
68
|
+
data = {} unless data.is_a?(Hash)
|
|
69
|
+
raise SecurityEventError.new((data["error"] || "server_error").to_s, "#{url} answered #{status}") unless status == 200
|
|
70
|
+
|
|
71
|
+
{ "sets" => data["sets"].is_a?(Hash) ? data["sets"] : {}, "more_available" => data["moreAvailable"] == true }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Checks a SET: signature (RS256, the issuer's keys), typ, issuer, audience, one event.
|
|
76
|
+
class SecurityEventVerifier
|
|
77
|
+
def initialize(issuer:, audience:, jwks_uri: nil, jwks: nil, clock_skew: 300, fetch: nil)
|
|
78
|
+
raise ConfigError, "SecurityEventVerifier needs issuer (the transmitter) and audience (your stream's aud)" if issuer.to_s.empty? || audience.to_s.empty?
|
|
79
|
+
|
|
80
|
+
@issuer = issuer
|
|
81
|
+
@audience = audience
|
|
82
|
+
@jwks_uri = jwks_uri
|
|
83
|
+
@skew = clock_skew
|
|
84
|
+
@fetch = fetch || CrossAppAccess.method(:get_json)
|
|
85
|
+
@pinned = !jwks.nil?
|
|
86
|
+
@keys = {}
|
|
87
|
+
@keys_at = 0
|
|
88
|
+
@tried_at = 0
|
|
89
|
+
@lock = Mutex.new
|
|
90
|
+
set_keys(jwks) if jwks
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The SET, checked: { "jti", "issuer", "audience" (list), "issued_at", "txn" (Touchque's case code),
|
|
94
|
+
# "subject" (sub_id), "type" (URI), "name" (short name, or nil), "claims" (the event's own) },
|
|
95
|
+
# or SecurityEventError.
|
|
96
|
+
def verify(token)
|
|
97
|
+
bad = ->(message) { SecurityEventError.new("invalid_request", message) }
|
|
98
|
+
parts = JWS.split(token.to_s.strip)
|
|
99
|
+
raise bad.call("not a JWT") unless parts
|
|
100
|
+
|
|
101
|
+
head, c, input, sig = parts
|
|
102
|
+
raise bad.call("not a Security Event Token (typ secevent+jwt)") unless head["typ"].to_s.downcase == "secevent+jwt"
|
|
103
|
+
raise SecurityEventError.new("invalid_key", "unsupported signature") unless head["alg"] == "RS256" && head["kid"].is_a?(String)
|
|
104
|
+
|
|
105
|
+
key = begin
|
|
106
|
+
key_for(head["kid"])
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
raise SecurityEventError.new("temporarily_unavailable", "the transmitter's keys could not be read: #{e.message}")
|
|
109
|
+
end
|
|
110
|
+
raise SecurityEventError.new("invalid_key", "signature does not verify") unless key && JWS.verify("RS256", key, input, sig)
|
|
111
|
+
raise SecurityEventError.new("invalid_issuer", "another issuer") unless c["iss"] == @issuer
|
|
112
|
+
|
|
113
|
+
audience = (c["aud"].is_a?(Array) ? c["aud"] : [c["aud"]]).select { |a| a.is_a?(String) }
|
|
114
|
+
raise SecurityEventError.new("invalid_audience", "not for this receiver") unless audience.include?(@audience)
|
|
115
|
+
raise bad.call("jti and iat are required") unless c["jti"].is_a?(String) && c["iat"].is_a?(Integer)
|
|
116
|
+
raise bad.call("issued in the future") if c["iat"] - @skew > Util.now
|
|
117
|
+
|
|
118
|
+
events = c["events"]
|
|
119
|
+
raise bad.call("a SET carries exactly one event") unless events.is_a?(Hash) && events.length == 1
|
|
120
|
+
|
|
121
|
+
type, claims = events.first
|
|
122
|
+
m = SecurityEvents::NAMESPACE.match(type)
|
|
123
|
+
{
|
|
124
|
+
"jti" => c["jti"], "issuer" => c["iss"], "audience" => audience, "issued_at" => c["iat"],
|
|
125
|
+
"txn" => c["txn"].is_a?(String) ? c["txn"] : nil, "subject" => c["sub_id"].is_a?(Hash) ? c["sub_id"] : {},
|
|
126
|
+
"type" => type, "name" => m ? m[2] : nil, "claims" => claims.is_a?(Hash) ? claims : {}
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def set_keys(jwks)
|
|
133
|
+
@keys = (jwks["keys"] || []).each_with_object({}) { |k, h| h[k["kid"]] = k if k["kid"] }
|
|
134
|
+
@keys_at = Util.now
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def load_keys
|
|
138
|
+
unless @jwks_uri
|
|
139
|
+
meta = @fetch.call("#{@issuer.chomp("/")}/.well-known/ssf-configuration")
|
|
140
|
+
raise "the transmitter's metadata does not match its issuer" unless meta["issuer"] == @issuer && meta["jwks_uri"].is_a?(String)
|
|
141
|
+
|
|
142
|
+
@jwks_uri = meta["jwks_uri"]
|
|
143
|
+
end
|
|
144
|
+
raise "#{@jwks_uri} must be an https URL" unless CrossAppAccess.safe_url?(@jwks_uri)
|
|
145
|
+
|
|
146
|
+
set_keys(@fetch.call(@jwks_uri))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def key_for(kid)
|
|
150
|
+
return @keys[kid] if @pinned
|
|
151
|
+
|
|
152
|
+
@lock.synchronize do
|
|
153
|
+
load_keys if @keys_at.zero? || Util.now - @keys_at > 3600
|
|
154
|
+
if !@keys.key?(kid) && Util.now - @tried_at > 300
|
|
155
|
+
@tried_at = Util.now
|
|
156
|
+
load_keys
|
|
157
|
+
end
|
|
158
|
+
@keys[kid]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|