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,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