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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Touchque
7
+ # Plain HTTP for the clients that do not sign with the API key (agents, Touchque
8
+ # Login, MCP). Never follows redirects.
9
+ module Transport
10
+ module_function
11
+
12
+ # [status, body text, response].
13
+ def call(method, url, headers: {}, body: nil, timeout: 10)
14
+ uri = URI.parse(url)
15
+ klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post, "PUT" => Net::HTTP::Put,
16
+ "PATCH" => Net::HTTP::Patch, "DELETE" => Net::HTTP::Delete }.fetch(method.to_s.upcase)
17
+ req = klass.new(uri.request_uri)
18
+ headers.each { |k, v| req[k] = v }
19
+ req["Accept"] ||= "application/json"
20
+ req.body = body unless body.nil?
21
+ http = Net::HTTP.new(uri.host, uri.port)
22
+ http.use_ssl = uri.scheme == "https"
23
+ http.open_timeout = timeout
24
+ http.read_timeout = timeout
25
+ res = http.request(req)
26
+ raise NetworkError, "the answer is larger than 10 MB" if res.body.to_s.bytesize > HTTP::MAX_RESPONSE_BYTES
27
+
28
+ [res.code.to_i, res.body.to_s.dup.force_encoding("UTF-8"), res]
29
+ rescue *HTTP::NETWORK_ERRORS => e
30
+ raise NetworkError, e.message
31
+ end
32
+
33
+ def form(fields)
34
+ URI.encode_www_form(fields.reject { |_, v| v.nil? || v.to_s.empty? })
35
+ end
36
+
37
+ # An OAuth-style error answer as APIError (code = error, retryAfter from retry_after).
38
+ def oauth_failure(status, data)
39
+ data = data.is_a?(Hash) ? data : {}
40
+ data["code"] ||= data["error"]
41
+ data["retryAfter"] ||= data["retry_after"].to_i if data["retry_after"]
42
+ APIError.for(status, data, data["error_description"] || data["error"] || "Touchque API error: HTTP #{status}")
43
+ end
44
+ end
45
+
46
+ # The agent side: an AI agent calls Touchque with its OWN key, never with your API
47
+ # key. It signs a private_key_jwt assertion (RFC 7523) for a 10-minute token bound to
48
+ # its key with DPoP (RFC 9449), and proves the key on every call, so a stolen token is
49
+ # useless on its own.
50
+ #
51
+ # agent = Touchque::Agent.new # TQ_AGENT_ID, TQ_AGENT_KEY (a JWK as JSON, or an EC PEM)
52
+ # approved = agent.require_approval("ayse@acme.com", "PAY_INVOICE", [payment]) do |state|
53
+ # say "Type #{state["challengeCode"]} on your phone"
54
+ # end
55
+ class Agent
56
+ ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
57
+ ENDED = %w[rejected blocked revoked expired stale redeemed].freeze
58
+
59
+ attr_reader :agent_id
60
+
61
+ def initialize(agent_id: nil, private_key: nil, kid: nil, base_url: nil, scope: nil, timeout: 10)
62
+ agent_id ||= ENV.fetch("TQ_AGENT_ID", "")
63
+ raise ConfigError, "Set TQ_AGENT_ID (or agent_id:): the agt_... id from agents.create" unless agent_id.match?(/\Aagt_[a-z2-9]{20}\z/)
64
+
65
+ private_key ||= ENV.fetch("TQ_AGENT_KEY", nil)
66
+ raise ConfigError, "Set TQ_AGENT_KEY (or private_key:): the agent's private key" if private_key.nil? || private_key.to_s.empty?
67
+
68
+ @agent_id = agent_id
69
+ @key = SigningKey.load(private_key, "Touchque::Agent")
70
+ jwk = private_key.is_a?(Hash) ? Util.stringify(private_key) : (private_key.to_s.lstrip.start_with?("{") ? Util.parse_json(private_key) : nil)
71
+ @kid = kid || (jwk.is_a?(Hash) && jwk["kid"]) || AgentKeys.thumbprint(@key.public_jwk)
72
+ @base_url = (base_url || (ENV["TQ_API_URL"].to_s.empty? ? DEFAULT_BASE_URL : ENV.fetch("TQ_API_URL"))).chomp("/")
73
+ Util.assert_safe_url!(@base_url, "base_url")
74
+ @scope = scope
75
+ @timeout = timeout
76
+ @token = nil
77
+ @expires_at = 0
78
+ @lock = Mutex.new
79
+ end
80
+
81
+ # The agent's current token (renewed a minute before it expires).
82
+ def access_token
83
+ @lock.synchronize do
84
+ return @token if @token && @expires_at - Util.now > 60
85
+
86
+ fetch_token
87
+ end
88
+ end
89
+
90
+ # The agent as Touchque sees it: its action types, owner, review date and workspace.
91
+ def me
92
+ request("GET", "/agents/me")
93
+ end
94
+
95
+ # Asks the person to approve on their Touchque phone. Show "challengeCode" in your
96
+ # conversation (presence interactive, the default): the person types it on the phone.
97
+ # Touchque draws the phone's rows from +authorization_details+ (RFC 9396).
98
+ # "requiresPasskey" (over the policy's requirePasskeyAbove): the person approves with a
99
+ # passkey on your page instead, not on the phone.
100
+ def request_approval(user, type, authorization_details, reason: nil, presence: nil, expires_in: nil, locale: nil,
101
+ reference_id: nil, client_ip: nil, user_agent: nil, mandate_id: nil)
102
+ body = { "externalUsername" => user, "type" => type, "authorizationDetails" => Util.stringify(authorization_details) }
103
+ { "reason" => reason, "presence" => presence, "expiresIn" => expires_in, "locale" => locale, "referenceId" => reference_id,
104
+ "clientIp" => client_ip, "userAgent" => user_agent, "mandateId" => mandate_id }.each { |k, v| body[k] = v unless v.nil? }
105
+ request("POST", "/agents/approvals", body)
106
+ end
107
+
108
+ # Asks the person for a mandate: one approval on the phone for a bounded series of
109
+ # requests. max_total: { currency: "TRY", amount: "1000" }. Once active, pass
110
+ # mandate_id: to request_approval: no push.
111
+ def request_mandate(user, type, max_count, max_total, per_request: nil, recipients: nil, valid_for: nil, reason: nil,
112
+ expires_in: nil, locale: nil)
113
+ body = { "externalUsername" => user, "type" => type, "maxCount" => max_count, "maxTotal" => Util.stringify(max_total) }
114
+ { "perRequest" => per_request && Util.stringify(per_request), "recipients" => recipients, "validFor" => valid_for, "reason" => reason,
115
+ "expiresIn" => expires_in, "locale" => locale }.each { |k, v| body[k] = v unless v.nil? }
116
+ request("POST", "/agents/mandates", body)
117
+ end
118
+
119
+ # The token vault: a person's own access token at another service, for a connection
120
+ # that names this agent: {"access_token", "token_type" => "Bearer", "expires_in",
121
+ # "scope", "connection"}. APIError not_connected when nothing is kept.
122
+ def get_connection_token(user, connection, scope: nil)
123
+ request("POST", "/agents/vault/token", Util.compact({ "externalUsername" => user, "connection" => connection, "scope" => scope }))
124
+ end
125
+
126
+ def get_mandate(mandate_id)
127
+ request("GET", "/agents/mandates/#{Util.seg(mandate_id)}")
128
+ end
129
+
130
+ def revoke_mandate(mandate_id)
131
+ request("POST", "/agents/mandates/#{Util.seg(mandate_id)}/revoke", {})
132
+ end
133
+
134
+ # Where an approval request is; "approvalToken" once approved.
135
+ def get_approval(approval_id)
136
+ request("GET", "/agents/approvals/#{Util.seg(approval_id)}")
137
+ end
138
+
139
+ # Asks the person's phone and waits for the answer: the approved state (holding
140
+ # "approvalToken"), or ApprovalError / TimeoutError. The block (or +on_challenge+)
141
+ # gets the state while the phone waits: show its "challengeCode". Put it in front of
142
+ # any tool call an agent framework makes, so the call runs only after the person approved.
143
+ def require_approval(user, type, authorization_details, on_challenge: nil, timeout: 300, **options, &block)
144
+ on_challenge ||= block
145
+ asked = request_approval(user, type, authorization_details, **options)
146
+ return asked if asked["status"] == "approved" && asked["approvalToken"]
147
+
148
+ on_challenge&.call(asked) if asked["challengeCode"]
149
+ wait_for_approval(asked["approvalId"], timeout: timeout)
150
+ end
151
+
152
+ # Polls until the person approves (the state holding "approvalToken"). ApprovalError
153
+ # when it ends otherwise, TimeoutError after +timeout+ seconds. An approval given
154
+ # during a phone call stays provisional until the phone confirms it.
155
+ def wait_for_approval(approval_id, timeout: 600, interval: 2.0)
156
+ interval = [interval, 0.5].max
157
+ deadline = Time.now + timeout
158
+ loop do
159
+ state = get_approval(approval_id)
160
+ return state if state["status"] == "approved" && state["approvalToken"]
161
+ raise ApprovalError.new(approval_id, state["status"], state["reason"]) if ENDED.include?(state["status"])
162
+ raise TimeoutError.new("Touchque: approval '#{approval_id}' was not approved within #{timeout}s", approval_id) if Time.now + interval > deadline
163
+
164
+ sleep interval
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ def fetch_token
171
+ url = "#{@base_url}/agents/token"
172
+ now = Util.now
173
+ assertion = @key.jws({ "typ" => "JWT", "kid" => @kid },
174
+ { "iss" => @agent_id, "sub" => @agent_id, "aud" => url, "iat" => now, "exp" => now + 60, "jti" => Util.random_hex(16) })
175
+ form = { "grant_type" => "client_credentials", "client_assertion_type" => ASSERTION_TYPE, "client_assertion" => assertion, "scope" => @scope }
176
+ status, text, = Transport.call("POST", url, headers: { "Content-Type" => "application/x-www-form-urlencoded", "DPoP" => dpop("POST", url) },
177
+ body: Transport.form(form), timeout: @timeout)
178
+ data = Util.parse_json(text)
179
+ raise Transport.oauth_failure(status, data) unless status == 200 && data.is_a?(Hash) && data["access_token"]
180
+
181
+ @token = data["access_token"]
182
+ @expires_at = Util.now + (data["expires_in"] || 600).to_i
183
+ @token
184
+ end
185
+
186
+ def dpop(method, url, access_token = nil)
187
+ claims = { "htm" => method, "htu" => url, "iat" => Util.now, "jti" => Util.random_hex(16) }
188
+ claims["ath"] = AgentKeys.access_token_hash(access_token) if access_token
189
+ @key.jws({ "typ" => "dpop+jwt", "jwk" => @key.public_jwk }, claims)
190
+ end
191
+
192
+ # A call with the agent's token and a fresh DPoP proof; one retry with a new token
193
+ # when Touchque no longer knows it.
194
+ def request(method, path, body = nil, retried: false)
195
+ token = access_token
196
+ url = @base_url + path
197
+ headers = { "Authorization" => "DPoP #{token}", "DPoP" => dpop(method, url.split("?").first, token) }
198
+ headers["Content-Type"] = "application/json" unless body.nil?
199
+ status, text, = Transport.call(method, url, headers: headers, body: body.nil? ? nil : Util.json(body), timeout: @timeout)
200
+ data = Util.parse_json(text)
201
+ data = {} unless data.is_a?(Hash)
202
+ if status == 401 && !retried && data["code"] == "invalid_token"
203
+ @lock.synchronize { @token = nil if @token == token }
204
+ return request(method, path, body, retried: true)
205
+ end
206
+ raise Transport.oauth_failure(status, data) unless (200..299).cover?(status)
207
+
208
+ data
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ # An A2A agent card that did not verify. +error+ is unsigned, malformed,
5
+ # untrusted_key, invalid_signature or keys_unavailable.
6
+ class AgentCardError < Error
7
+ attr_reader :error
8
+
9
+ def initialize(error, message)
10
+ @error = error
11
+ super(message)
12
+ end
13
+ end
14
+
15
+ # A2A agent card signatures (A2A 1.0 §8.4): before your agent trusts another agent's
16
+ # card (its name, URL, skills), check that whoever controls its keys signed exactly
17
+ # this card. Each entry of +signatures+ is a JWS (RFC 7515) over the card in RFC 8785
18
+ # canonical form without +signatures+; the protected header names alg, kid and
19
+ # optionally jku (where the keys are). Keys come from +keys+ or from the header's
20
+ # jku, but jku is only followed on the card's own host (or a host in
21
+ # +trusted_jku_hosts+): a card that points at keys on a host of its choosing proves
22
+ # nothing. Signers must leave out fields at their default values (A2A §8.4.1); the
23
+ # card is checked as received.
24
+ #
25
+ # json = Net::HTTP.get(URI("https://agent.example/.well-known/agent-card.json"))
26
+ # Touchque::AgentCard.verify(json, card_url: "https://agent.example/.well-known/agent-card.json")
27
+ module AgentCard
28
+ ALGS = %w[RS256 ES256 EdDSA].freeze
29
+
30
+ module_function
31
+
32
+ # A number as ECMAScript's Number::toString writes it (RFC 8785 §3.2.2.3).
33
+ def es_number(value)
34
+ return value.to_s if value.is_a?(Integer)
35
+ raise AgentCardError.new("malformed", "a number is not finite") unless value.finite?
36
+ return "0" if value.zero?
37
+
38
+ sign = value.negative? ? "-" : ""
39
+ mantissa, exp = value.abs.to_s.split(/e/i)
40
+ int_part, frac = mantissa.split(".")
41
+ digits = int_part + frac.to_s
42
+ n = int_part.length + exp.to_i
43
+ trimmed = digits.sub(/\A0+/, "")
44
+ n -= digits.length - trimmed.length
45
+ digits = trimmed.sub(/0+\z/, "")
46
+ k = digits.length
47
+ out = if k <= n && n <= 21
48
+ digits + ("0" * (n - k))
49
+ elsif n.positive? && n <= 21
50
+ "#{digits[0, n]}.#{digits[n..]}"
51
+ elsif n > -6 && n <= 0
52
+ "0.#{"0" * -n}#{digits}"
53
+ else
54
+ x = n - 1
55
+ "#{digits[0]}#{k > 1 ? ".#{digits[1..]}" : ""}e#{x >= 0 ? "+" : "-"}#{x.abs}"
56
+ end
57
+ sign + out
58
+ end
59
+
60
+ # RFC 8785 (JSON Canonicalization Scheme): sorted keys (UTF-16 order), no whitespace, ECMAScript numbers.
61
+ def canonical_json(value)
62
+ case value
63
+ when nil then "null"
64
+ when true then "true"
65
+ when false then "false"
66
+ when String then Util.js_string(value)
67
+ when Integer, Float then es_number(value)
68
+ when Array then "[#{value.map { |v| canonical_json(v) }.join(",")}]"
69
+ when Hash
70
+ keys = value.keys.map(&:to_s).sort_by { |k| k.encode("UTF-16BE").b }
71
+ h = Util.stringify(value)
72
+ "{#{keys.map { |k| "#{Util.js_string(k)}:#{canonical_json(h[k])}" }.join(",")}}"
73
+ else
74
+ raise AgentCardError.new("malformed", "cannot canonicalize #{value.class}")
75
+ end
76
+ end
77
+
78
+ def decode(card)
79
+ c = card.is_a?(String) ? Util.parse_json(card) : Util.stringify(card)
80
+ raise AgentCardError.new("malformed", "the card is not a JSON object") unless c.is_a?(Hash)
81
+
82
+ c
83
+ end
84
+
85
+ def payload(card)
86
+ Util.b64url(canonical_json(card.reject { |k, _| k == "signatures" }))
87
+ end
88
+
89
+ def host_of(url)
90
+ return nil unless url && CrossAppAccess.safe_url?(url)
91
+
92
+ u = URI.parse(url)
93
+ u.port == u.default_port ? u.host : "#{u.host}:#{u.port}"
94
+ end
95
+
96
+ # Checks the card's signatures: { "kid", "alg", "key_source" => "keys" | "jku" } or AgentCardError.
97
+ def verify(card, card_url: nil, keys: nil, trusted_jku_hosts: [], fetch: nil)
98
+ c = decode(card)
99
+ sigs = c["signatures"]
100
+ raise AgentCardError.new("unsigned", "the card has no signatures") unless sigs.is_a?(Array) && !sigs.empty?
101
+
102
+ given = Util.stringify(keys.is_a?(Hash) ? (keys["keys"] || keys[:keys] || []) : (keys || []))
103
+ card_host = host_of(card_url)
104
+ fetch ||= CrossAppAccess.method(:get_json)
105
+ body = payload(c)
106
+ last = AgentCardError.new("invalid_signature", "no signature verifies")
107
+ sigs.each do |s|
108
+ next unless s.is_a?(Hash) && s["protected"].is_a?(String) && s["signature"].is_a?(String)
109
+
110
+ head = Util.parse_json(Util.b64url_decode(s["protected"]).force_encoding("UTF-8"))
111
+ next unless head.is_a?(Hash)
112
+
113
+ alg = head["alg"]
114
+ next unless ALGS.include?(alg) && head["kid"].is_a?(String) && !head.key?("crit")
115
+ next if head.key?("typ") && head["typ"] != "JOSE"
116
+
117
+ jwk = given.find { |k| k["kid"] == head["kid"] }
118
+ source = "keys"
119
+ if jwk.nil? && head["jku"].is_a?(String)
120
+ jku_host = host_of(head["jku"])
121
+ unless jku_host && (jku_host == card_host || Array(trusted_jku_hosts).include?(jku_host))
122
+ last = AgentCardError.new("untrusted_key", "jku #{head["jku"]} is not on the card's host")
123
+ next
124
+ end
125
+ begin
126
+ jwk = (fetch.call(head["jku"])["keys"] || []).find { |k| k["kid"] == head["kid"] }
127
+ rescue StandardError => e
128
+ last = AgentCardError.new("keys_unavailable", "the keys at #{head["jku"]} could not be read: #{e.message}")
129
+ next
130
+ end
131
+ source = "jku"
132
+ end
133
+ if jwk.nil?
134
+ last = AgentCardError.new("untrusted_key", "no trusted key #{head["kid"]}") if last.error == "invalid_signature"
135
+ next
136
+ end
137
+ return { "kid" => head["kid"], "alg" => alg, "key_source" => source } if JWS.verify(alg, jwk, "#{s["protected"]}.#{body}", Util.b64url_decode(s["signature"]))
138
+
139
+ last = AgentCardError.new("invalid_signature", "the signature does not match the card")
140
+ rescue ArgumentError
141
+ next
142
+ end
143
+ raise last
144
+ end
145
+
146
+ # Signs a card you publish: the card JSON with one more entry in +signatures+.
147
+ # The key is an ES256 or EdDSA private JWK (Hash or JSON) or an EC P-256 PEM.
148
+ def sign(card, private_key, kid:, jku: nil)
149
+ c = decode(card)
150
+ key = SigningKey.load(private_key, "AgentCard.sign")
151
+ header = { "alg" => key.alg, "typ" => "JOSE", "kid" => kid }
152
+ header["jku"] = jku if jku
153
+ protected_header = Util.b64url(Util.json(header))
154
+ sig = key.sign("#{protected_header}.#{payload(c)}")
155
+ c["signatures"] = Array(c["signatures"]) + [{ "protected" => protected_header, "signature" => Util.b64url(sig) }]
156
+ Util.json(c)
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ # Transaction details the way the API normalizes and digests them.
5
+ module Details
6
+ # What JavaScript's trim removes, as the API trims: ASCII and Unicode spaces (U+00A0,
7
+ # U+202F...), line separators and the BOM. String#strip knows only the ASCII ones.
8
+ SPACE = "[\\t\\n\\v\\f\\r \\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]"
9
+ TRIM = /\A#{SPACE}+|#{SPACE}+\z/
10
+
11
+ module_function
12
+
13
+ # A Hash ({"Amount" => "250 EUR"}) or a list of {label:, value:} as
14
+ # [{"label", "value"}] with trimmed strings.
15
+ def normalize(details)
16
+ return [] if details.nil? || details.empty?
17
+
18
+ pairs = if details.is_a?(Hash)
19
+ details.map { |label, value| [label.to_s, value.to_s] }
20
+ else
21
+ details.map do |d|
22
+ d = Util.stringify(d)
23
+ [d["label"].to_s, d["value"].to_s]
24
+ end
25
+ end
26
+ pairs.map { |label, value| { "label" => trim(label), "value" => trim(value) } }
27
+ end
28
+
29
+ def trim(text)
30
+ text.ascii_only? || text.encoding == Encoding::UTF_8 ? text.gsub(TRIM, "") : text.strip
31
+ end
32
+
33
+ # SHA-256 (hex) over the normalized details joined as label US value RS label...;
34
+ # "" for no details. The phone signs this digest (ApprovalProof).
35
+ def digest(details)
36
+ pairs = normalize(details)
37
+ return "" if pairs.empty?
38
+
39
+ Util.sha256_hex(pairs.map { |p| "#{p["label"]}\u001f#{p["value"]}" }.join("\u001e"))
40
+ end
41
+ end
42
+
43
+ # Verifies the device-signed proof of an approval ("what you see is what you sign").
44
+ # When the phone holds a hardware key (Secure Enclave / Android Keystore), every
45
+ # approval is signed over the request id, the action type, the number-match code and
46
+ # a digest of the details the user was shown. The proof arrives in the SUCCESS webhook
47
+ # ("approvalProof") and in the consumed approval. Verifying it does not require
48
+ # trusting Touchque.
49
+ module ApprovalProof
50
+ PREFIX = "touchque-approve:v2"
51
+
52
+ module_function
53
+
54
+ # True only if +proof+ was signed by the device key over exactly the request, action
55
+ # type, challenge code and details you expect. Pin the device with
56
+ # +expected_public_key+ (the base64 key you stored for this user), or pass
57
+ # allow_unpinned_key: true for trust on first use. Never raises.
58
+ def verify(proof, request_id:, type:, details: nil, challenge_code: nil, expected_public_key: nil, allow_unpinned_key: false)
59
+ return false unless proof.is_a?(Hash)
60
+
61
+ proof = Util.stringify(proof)
62
+ return false unless proof["version"] == "v2" && proof["algorithm"] == "ECDSA-P256-SHA256"
63
+
64
+ public_key = proof["publicKey"]
65
+ message = proof["message"]
66
+ signature = proof["signature"]
67
+ return false unless [public_key, message, signature].all?(String)
68
+
69
+ if expected_public_key && !expected_public_key.empty?
70
+ return false unless Util.secure_compare(expected_public_key, public_key)
71
+ elsif allow_unpinned_key != true
72
+ return false
73
+ end
74
+ expected = [PREFIX, request_id.to_s, type.to_s, challenge_code.to_s, Details.digest(details)].join("\n")
75
+ return false unless Util.secure_compare(expected, message)
76
+
77
+ raw = public_key.unpack1("m0")
78
+ return false unless raw.bytesize == 65 && raw.getbyte(0) == 4
79
+
80
+ sig = signature.unpack1("m0")
81
+ return false if sig.empty?
82
+
83
+ key = OpenSSL::PKey.read(AgentKeys::P256_SPKI + raw)
84
+ key.verify(OpenSSL::Digest.new("SHA256"), sig, message)
85
+ rescue StandardError
86
+ false
87
+ end
88
+ end
89
+
90
+ # What a partner's server checks before executing what an AI agent asked for: the
91
+ # approval token's claims match the transaction (person, action type, authorization
92
+ # details) and, offline, its ES256 signature against Touchque's keys
93
+ # (GET /agents/jwks.json). Amounts compare as decimals ("500" = "500.00"), IBANs
94
+ # without spaces in any case, currencies in any case; a Hash matches a token with
95
+ # exactly one entry, an Array matches entry by entry.
96
+ module ApprovalToken
97
+ module_function
98
+
99
+ # {"header", "claims"} WITHOUT checking the signature.
100
+ def decode(token)
101
+ parts = JWS.split(token)
102
+ raise ApprovalMismatchError, "not an approval token" unless parts
103
+
104
+ { "header" => parts[0], "claims" => parts[1] }
105
+ end
106
+
107
+ # Does +details+ (a token's authorization_details) say what +expected+ says?
108
+ def matches?(details, expected)
109
+ return false unless details.is_a?(Array)
110
+
111
+ expected = Util.stringify(expected)
112
+ if expected.is_a?(Array)
113
+ return false unless details.length == expected.length
114
+
115
+ return expected.each_with_index.all? { |e, i| partial(details[i], e) }
116
+ end
117
+ details.length == 1 && partial(details[0], expected)
118
+ end
119
+
120
+ # Raises ApprovalMismatchError unless the claims say what you expect.
121
+ def assert_matches!(claims, user: nil, type: nil, authorization_details: nil)
122
+ if user && claims["sub"] != user
123
+ raise ApprovalMismatchError, "the approval is for another person"
124
+ end
125
+ if type && claims["type"] != type
126
+ raise ApprovalMismatchError, "the approval is for another action type (#{claims["type"]})"
127
+ end
128
+ if authorization_details && !matches?(claims["authorization_details"], authorization_details)
129
+ raise ApprovalMismatchError, "the approved details differ from what you are about to execute"
130
+ end
131
+
132
+ true
133
+ end
134
+
135
+ # Verifies an approval token offline: typ touchque-approval+jwt, ES256 by a key of
136
+ # +jwks+, not expired (30 s leeway), aud equal to +audience+, then the expectation.
137
+ # +audience+ is required: /agents/jwks.json is one key set for every Touchque
138
+ # workspace, so without it a token Touchque signed for another workspace would pass.
139
+ # Pass your own (tq.agents.approval_audience, touchque:workspace:<id>), or use
140
+ # tq.agents.verify_approval_token, which fills it in.
141
+ # Does NOT make it single-use: agents.redeem_approval does.
142
+ def verify_with(token, jwks, audience: nil, user: nil, type: nil, authorization_details: nil, now: nil)
143
+ if !audience.is_a?(String) || audience.empty?
144
+ raise ApprovalMismatchError, "audience is required: your workspace's touchque:workspace:<id> (tq.agents.approval_audience)"
145
+ end
146
+ parts = JWS.split(token)
147
+ raise ApprovalMismatchError, "not an approval token" unless parts
148
+
149
+ header, claims, input, sig = parts
150
+ unless header["typ"] == "touchque-approval+jwt" && header["alg"] == "ES256"
151
+ raise ApprovalMismatchError, "not an approval token (typ/alg)"
152
+ end
153
+
154
+ jwk = (Util.stringify(jwks)["keys"] || []).reverse.find do |k|
155
+ k["kid"] == header["kid"] && k["kty"] == "EC" && k["crv"] == "P-256"
156
+ end
157
+ raise ApprovalMismatchError, "signed by an unknown key" unless jwk
158
+ raise ApprovalMismatchError, "the signature does not verify" unless JWS.verify("ES256", jwk, input, sig)
159
+
160
+ exp = claims["exp"]
161
+ raise ApprovalMismatchError, "the approval token expired" unless exp.is_a?(Numeric) && exp + 30 >= (now || Util.now)
162
+ aud = claims["aud"]
163
+ raise ApprovalMismatchError, "the approval token names no workspace" if !aud.is_a?(String) || aud.empty?
164
+ raise ApprovalMismatchError, "the approval token is for another workspace" if aud != audience
165
+
166
+ assert_matches!(claims, user: user, type: type, authorization_details: authorization_details)
167
+ claims
168
+ end
169
+
170
+ def decimal(value)
171
+ text = value.to_s.strip
172
+ return nil unless text.match?(/\A\d+(\.\d+)?\z/)
173
+
174
+ whole, frac = text.split(".", 2)
175
+ whole = whole.sub(/\A0+/, "")
176
+ whole = "0" if whole.empty?
177
+ frac = (frac || "").sub(/0+\z/, "")
178
+ frac.empty? ? whole : "#{whole}.#{frac}"
179
+ end
180
+
181
+ def same(key, actual, expected)
182
+ return partial(actual, expected) if expected.is_a?(Hash) || expected.is_a?(Array)
183
+
184
+ case key
185
+ when "amount"
186
+ !decimal(actual).nil? && decimal(actual) == decimal(expected)
187
+ when "iban", "currency"
188
+ actual.is_a?(String) && actual.gsub(/\s+/, "").upcase == expected.to_s.gsub(/\s+/, "").upcase
189
+ else
190
+ scalar_equal(actual, expected)
191
+ end
192
+ end
193
+
194
+ def partial(actual, expected)
195
+ if expected.is_a?(Array)
196
+ return false unless actual.is_a?(Array) && actual.length == expected.length
197
+
198
+ return expected.each_with_index.all? do |e, i|
199
+ e.is_a?(Hash) || e.is_a?(Array) ? partial(actual[i], e) : scalar_equal(actual[i], e)
200
+ end
201
+ end
202
+ return scalar_equal(actual, expected) unless expected.is_a?(Hash)
203
+ return false unless actual.is_a?(Hash)
204
+
205
+ expected.all? { |k, v| actual.key?(k) && same(k, actual[k], v) }
206
+ end
207
+
208
+ def scalar_equal(a, b)
209
+ return a == b unless a.is_a?(Numeric) && b.is_a?(Numeric)
210
+
211
+ a.to_r == b.to_r
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Touchque
4
+ DEFAULT_BASE_URL = "https://api.touchque.com"
5
+
6
+ # The Touchque client. Every call is signed with your API secret, so it belongs on
7
+ # your server; browser pieces relay through it.
8
+ #
9
+ # # TQ_API_KEY=tq_... TQ_API_SECRET=...
10
+ # tq = Touchque::Client.new
11
+ # step = tq.start("SEND_MONEY", "jane@acme.com", details: { "Amount" => "250 EUR" })
12
+ #
13
+ # Thread-safe: create one and reuse it (in Rails, Touchque.client).
14
+ class Client
15
+ attr_reader :auth, :login, :webauthn, :webhook, :offline, :action_types, :actions, :risk, :recovery,
16
+ :assist, :agents, :identity, :mobile, :qr_pairings, :device_risk, :safe_guard
17
+
18
+ # Anything left nil falls back to the environment: TQ_API_KEY, TQ_API_SECRET and
19
+ # TQ_API_URL (default https://api.touchque.com). +timeout+ is seconds per request.
20
+ def initialize(api_key: nil, api_secret: nil, base_url: nil, timeout: 10)
21
+ api_key ||= ENV.fetch("TQ_API_KEY", nil)
22
+ api_secret ||= ENV.fetch("TQ_API_SECRET", nil)
23
+ base_url ||= ENV["TQ_API_URL"].to_s.empty? ? DEFAULT_BASE_URL : ENV.fetch("TQ_API_URL")
24
+ if api_key.to_s.empty? || api_secret.to_s.empty?
25
+ raise ConfigError, "Set TQ_API_KEY and TQ_API_SECRET (or pass api_key: and api_secret:)."
26
+ end
27
+ raise ConfigError, "api_key must start with \"tq_\". Did you accidentally swap api_key and api_secret?" unless api_key.start_with?("tq_")
28
+
29
+ Util.assert_safe_url!(base_url, "base_url")
30
+ @http = HTTP.new(api_key: api_key, api_secret: api_secret, base_url: base_url.chomp("/"), timeout: timeout)
31
+ @auth = Resources::Auth.new(@http)
32
+ @login = Resources::Login.new(@http)
33
+ @webauthn = Resources::WebAuthn.new(@http)
34
+ @webhook = Resources::Webhook.new(api_secret)
35
+ @offline = Resources::Offline.new(@http)
36
+ @action_types = Resources::ActionTypes.new(@http)
37
+ @actions = Resources::Actions.new(@http)
38
+ @risk = Resources::Risk.new(@http)
39
+ @recovery = Resources::Recovery.new(@http)
40
+ @assist = Resources::Assist.new(@http)
41
+ @agents = Resources::Agents.new(@http)
42
+ @identity = Resources::Identity.new(@http)
43
+ @mobile = Resources::Mobile.new(@http)
44
+ @qr_pairings = Resources::QrPairings.new(@http)
45
+ @device_risk = Resources::DeviceRisk.new(@http)
46
+ @safe_guard = Resources::SafeGuard.new(@http)
47
+ end
48
+
49
+ # @api private: signs the guard tokens.
50
+ def api_secret
51
+ @http.api_secret
52
+ end
53
+
54
+ # Starts an approval and returns at once: show the step in your UI. "waiting" with
55
+ # "number": show the number, the user picks it on the phone. "enroll": show
56
+ # step["enroll"]["qrCodeDataUrl"] so the user links the Touchque app first.
57
+ # +browser_proof+ (silent sign-ins): the X-Touchque-Browser header, passed through.
58
+ def start(action, user, details: nil, reference_id: nil, ip: nil, user_agent: nil, browser_proof: nil, context: nil)
59
+ Steps.start(self, action, user, details: details, reference_id: reference_id, ip: ip, user_agent: user_agent,
60
+ browser_proof: browser_proof, context: context)
61
+ end
62
+
63
+ # Where a started approval is now: waiting, approved, rejected, expired...
64
+ def check(request_id)
65
+ Steps.check(self, request_id)
66
+ end
67
+
68
+ # Uses an approved request exactly once, after checking it is for this user, action
69
+ # and transaction. Call it right before doing the protected thing.
70
+ def complete(request_id, user, action, details: nil, reference_id: nil)
71
+ Steps.complete(self, request_id, user, action, details: details, reference_id: reference_id)
72
+ end
73
+
74
+ # Runs the block only after the user approved on the phone (a push and the wait of
75
+ # login.verify).
76
+ def protect(user, type: "LOGIN", **options)
77
+ login.verify(user, type: type, **options)
78
+ yield
79
+ end
80
+ end
81
+ end