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