touchque-authenticator 4.2.0__py3-none-any.whl

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 (48) hide show
  1. touchque/__init__.py +148 -0
  2. touchque/agent.py +314 -0
  3. touchque/agent_card.py +190 -0
  4. touchque/agent_frameworks.py +215 -0
  5. touchque/approval_proof.py +104 -0
  6. touchque/approval_token.py +134 -0
  7. touchque/client.py +105 -0
  8. touchque/code_reply.py +75 -0
  9. touchque/config.py +56 -0
  10. touchque/contrib/__init__.py +4 -0
  11. touchque/contrib/django.py +175 -0
  12. touchque/contrib/fastapi.py +208 -0
  13. touchque/contrib/flask.py +154 -0
  14. touchque/cross_app_access.py +222 -0
  15. touchque/exceptions.py +170 -0
  16. touchque/guard.py +290 -0
  17. touchque/hosted_login.py +474 -0
  18. touchque/http_client.py +156 -0
  19. touchque/mcp_auth.py +256 -0
  20. touchque/my_account.py +173 -0
  21. touchque/org_agent.py +262 -0
  22. touchque/rate_limiter.py +56 -0
  23. touchque/relay.py +643 -0
  24. touchque/resources/__init__.py +1 -0
  25. touchque/resources/action_types.py +52 -0
  26. touchque/resources/actions.py +234 -0
  27. touchque/resources/agents.py +236 -0
  28. touchque/resources/assist.py +115 -0
  29. touchque/resources/auth.py +65 -0
  30. touchque/resources/device_risk.py +174 -0
  31. touchque/resources/identity.py +268 -0
  32. touchque/resources/login.py +296 -0
  33. touchque/resources/mobile.py +130 -0
  34. touchque/resources/offline.py +138 -0
  35. touchque/resources/qr_pairings.py +93 -0
  36. touchque/resources/recovery.py +118 -0
  37. touchque/resources/risk.py +141 -0
  38. touchque/resources/safe_guard.py +99 -0
  39. touchque/resources/webauthn.py +115 -0
  40. touchque/resources/webhook.py +258 -0
  41. touchque/safe_guard.py +308 -0
  42. touchque/security_events.py +205 -0
  43. touchque/steps.py +239 -0
  44. touchque_authenticator-4.2.0.dist-info/METADATA +630 -0
  45. touchque_authenticator-4.2.0.dist-info/RECORD +48 -0
  46. touchque_authenticator-4.2.0.dist-info/WHEEL +5 -0
  47. touchque_authenticator-4.2.0.dist-info/licenses/LICENSE +21 -0
  48. touchque_authenticator-4.2.0.dist-info/top_level.txt +1 -0
touchque/__init__.py ADDED
@@ -0,0 +1,148 @@
1
+ import logging as _logging
2
+
3
+ from .config import Config
4
+ from .client import TouchQue, get_default_client
5
+ from .exceptions import (
6
+ TouchQueException,
7
+ TouchQueAPIException,
8
+ TouchQueFeatureException,
9
+ TouchQueFeatureNotInPlanException,
10
+ TouchQueFeatureReadOnlyException,
11
+ TouchQueFeatureOffException,
12
+ TouchQueNetworkException,
13
+ TouchQueTimeoutException,
14
+ TouchQueRejectedException,
15
+ TouchQueWebhookSignatureException,
16
+ TouchQueWebhookReplayException,
17
+ TouchQueConfigException,
18
+ TouchQuePasskeyRequiredError,
19
+ TouchQueApprovalError,
20
+ TouchQueApprovalMismatchError,
21
+ )
22
+ from .resources.webhook import ReplayCache, MemoryReplayCache, WebhookReasons, WebhookEvents
23
+ from .steps import normalize_details, details_digest
24
+ from .approval_proof import verify_approval_proof
25
+ from .code_reply import new_code_reply_key, open_code_reply
26
+ from .safe_guard import (
27
+ safe_approval_message, safe_display_digest, safe_governance_hash, safe_schedule_call, safe_tx_hash, verify_safe_approval,
28
+ with_safe_approvals,
29
+ )
30
+ from .guard import run_guard, guard_input_from_headers, silent_input_from_headers, BROWSER_HEADER
31
+ from .resources.risk import parse_risk_check
32
+ from .resources.recovery import is_recovery_open
33
+ from .resources.mobile import is_mobile_push_expired, mobile_push_from_webhook
34
+ from .resources.assist import is_assist_check_passed
35
+ from .resources.qr_pairings import QR_PAIRING_REFUSAL_REASONS, qr_pairing_uri
36
+ from .resources.device_risk import DEVICE_RISK_EVENTS, DEVICE_RISK_REPORTS, parse_device_risk_check
37
+ from .resources.action_types import SECOND_THOUGHT_QUESTIONS
38
+ from .relay import RelayHandler, RelayResponse, RELAY_ROUTES
39
+ from .rate_limiter import FixedWindowLimiter
40
+ from .agent import TouchQueAgent, generate_agent_key, jwk_thumbprint
41
+ from .agent_frameworks import (
42
+ ApprovalPolicy, approve_interruptions_with_touchque, current_approval, guard_mcp_session, require_agent_approval,
43
+ touchque_can_use_tool, with_touchque_approval,
44
+ )
45
+ from .org_agent import TouchQueOrgAgent, file_token, github_actions_token
46
+ from .mcp_auth import McpAuth, McpAuthError
47
+ from .cross_app_access import (
48
+ ID_JAG_TOKEN_TYPE, IdJagError, IdJagVerifier, redeem_id_jag, request_id_jag,
49
+ )
50
+ from .security_events import (
51
+ SECURITY_EVENT_TYPES, SecurityEvent, SecurityEventError, SecurityEventVerifier, poll_security_events,
52
+ )
53
+ from .agent_card import AgentCardError, canonical_json, sign_agent_card, verify_agent_card
54
+ from .approval_token import matches_authorization_details, verify_approval_token_with
55
+ from .hosted_login import HostedLogin, HostedLoginError, AuthorizationRequest, TokenSet, UserApprovalRequest, CONNECTION_TOKEN_TYPE
56
+ from .my_account import MyAccount, MyAccountError
57
+
58
+ __all__ = [
59
+ 'TouchQue',
60
+ 'Config',
61
+ 'get_default_client',
62
+ 'TouchQueException',
63
+ 'TouchQueAPIException',
64
+ 'TouchQueFeatureException',
65
+ 'TouchQueFeatureNotInPlanException',
66
+ 'TouchQueFeatureReadOnlyException',
67
+ 'TouchQueFeatureOffException',
68
+ 'TouchQueNetworkException',
69
+ 'TouchQueTimeoutException',
70
+ 'TouchQueRejectedException',
71
+ 'TouchQueWebhookSignatureException',
72
+ 'TouchQueWebhookReplayException',
73
+ 'ReplayCache',
74
+ 'MemoryReplayCache',
75
+ 'WebhookReasons',
76
+ 'WebhookEvents',
77
+ 'TouchQueConfigException',
78
+ 'TouchQuePasskeyRequiredError',
79
+ 'normalize_details',
80
+ 'details_digest',
81
+ 'verify_approval_proof',
82
+ 'new_code_reply_key',
83
+ 'open_code_reply',
84
+ 'safe_tx_hash',
85
+ 'safe_governance_hash',
86
+ 'safe_approval_message',
87
+ 'safe_display_digest',
88
+ 'verify_safe_approval',
89
+ 'with_safe_approvals',
90
+ 'safe_schedule_call',
91
+ 'run_guard',
92
+ 'guard_input_from_headers',
93
+ 'silent_input_from_headers',
94
+ 'BROWSER_HEADER',
95
+ 'parse_risk_check',
96
+ 'is_recovery_open',
97
+ 'is_mobile_push_expired',
98
+ 'mobile_push_from_webhook',
99
+ 'is_assist_check_passed',
100
+ 'QR_PAIRING_REFUSAL_REASONS',
101
+ 'qr_pairing_uri',
102
+ 'DEVICE_RISK_EVENTS',
103
+ 'DEVICE_RISK_REPORTS',
104
+ 'parse_device_risk_check',
105
+ 'SECOND_THOUGHT_QUESTIONS',
106
+ 'RelayHandler',
107
+ 'RelayResponse',
108
+ 'RELAY_ROUTES',
109
+ 'FixedWindowLimiter',
110
+ 'TouchQueAgent',
111
+ 'ApprovalPolicy',
112
+ 'with_touchque_approval',
113
+ 'current_approval',
114
+ 'require_agent_approval',
115
+ 'approve_interruptions_with_touchque',
116
+ 'touchque_can_use_tool',
117
+ 'guard_mcp_session',
118
+ 'generate_agent_key',
119
+ 'TouchQueOrgAgent',
120
+ 'github_actions_token',
121
+ 'file_token',
122
+ 'McpAuth',
123
+ 'McpAuthError',
124
+ 'ID_JAG_TOKEN_TYPE',
125
+ 'IdJagError',
126
+ 'IdJagVerifier',
127
+ 'SECURITY_EVENT_TYPES', 'SecurityEvent', 'SecurityEventError', 'SecurityEventVerifier', 'poll_security_events',
128
+ 'request_id_jag',
129
+ 'redeem_id_jag',
130
+ 'AgentCardError',
131
+ 'canonical_json',
132
+ 'sign_agent_card',
133
+ 'verify_agent_card',
134
+ 'jwk_thumbprint',
135
+ 'HostedLogin',
136
+ 'CONNECTION_TOKEN_TYPE',
137
+ 'HostedLoginError',
138
+ 'UserApprovalRequest',
139
+ 'MyAccount',
140
+ 'MyAccountError',
141
+ 'AuthorizationRequest',
142
+ 'TokenSet',
143
+ ]
144
+
145
+ # The SDK's own problems (a misconfigured guard, a failed on_challenge, a relay without
146
+ # get_login_user) go to logging.getLogger('touchque'). Silent by default: nothing is printed
147
+ # unless your app configures logging (logging.basicConfig, or a handler on 'touchque').
148
+ _logging.getLogger('touchque').addHandler(_logging.NullHandler())
touchque/agent.py ADDED
@@ -0,0 +1,314 @@
1
+ """The agent side: an AI agent calls TouchQue with its OWN key, never with your
2
+ API key. ``TouchQueAgent`` signs a private_key_jwt assertion (RFC 7523) for a
3
+ 10-minute token bound to its key with DPoP (RFC 9449), and proves the key on
4
+ every call, so a stolen token is useless on its own.
5
+
6
+ agent = TouchQueAgent() # TQ_AGENT_ID, TQ_AGENT_KEY (a JWK as JSON, or PEM)
7
+ me = agent.me()
8
+
9
+ Needs the optional ``cryptography`` package: ``pip install "touchque-authenticator[agent]"``.
10
+ """
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import threading
17
+ import time
18
+ import uuid
19
+ from typing import Any, Dict, List, Optional, Union
20
+ from urllib.parse import urlparse
21
+
22
+ import requests
23
+
24
+ from .exceptions import (
25
+ TouchQueAPIException, TouchQueApprovalError, TouchQueConfigException, TouchQueNetworkException, TouchQueTimeoutException,
26
+ api_exception,
27
+ )
28
+
29
+ __all__ = ['TouchQueAgent', 'generate_agent_key', 'jwk_thumbprint', 'AGENT_ASSERTION_TYPE']
30
+
31
+ AGENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
32
+ _AGENT_ID = re.compile(r'^agt_[a-z2-9]{20}$')
33
+ _LOCAL_HOSTS = {'localhost', '127.0.0.1', '::1'}
34
+ _ENDED = {'rejected', 'blocked', 'revoked', 'expired', 'stale', 'redeemed'}
35
+
36
+
37
+ def _b64url(data: bytes) -> str:
38
+ return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii')
39
+
40
+
41
+ def _b64url_decode(text: str) -> bytes:
42
+ return base64.urlsafe_b64decode(text + '=' * (-len(text) % 4))
43
+
44
+
45
+ def _json(obj: Dict[str, Any]) -> str:
46
+ return json.dumps(obj, separators=(',', ':'))
47
+
48
+
49
+ def jwk_thumbprint(jwk: Dict[str, Any]) -> str:
50
+ """RFC 7638 thumbprint of a P-256 or Ed25519 public key (the default kid)."""
51
+ if jwk.get('kty') == 'EC':
52
+ members = {'crv': jwk['crv'], 'kty': 'EC', 'x': jwk['x'], 'y': jwk['y']}
53
+ else:
54
+ members = {'crv': jwk['crv'], 'kty': jwk['kty'], 'x': jwk['x']}
55
+ return _b64url(hashlib.sha256(_json(members).encode('utf-8')).digest())
56
+
57
+
58
+ def _crypto():
59
+ try:
60
+ from cryptography.hazmat.primitives import hashes, serialization
61
+ from cryptography.hazmat.primitives.asymmetric import ec, ed25519
62
+ from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
63
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
64
+ raise ImportError('TouchQueAgent needs the cryptography package: pip install "touchque-authenticator[agent]"') from exc
65
+ return hashes, serialization, ec, ed25519, decode_dss_signature
66
+
67
+
68
+ def _public_jwk(key) -> Dict[str, Any]:
69
+ hashes, serialization, ec, ed25519, _ = _crypto()
70
+ if isinstance(key, ed25519.Ed25519PrivateKey):
71
+ raw = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
72
+ return {'kty': 'OKP', 'crv': 'Ed25519', 'x': _b64url(raw)}
73
+ numbers = key.public_key().public_numbers()
74
+ return {'kty': 'EC', 'crv': 'P-256', 'x': _b64url(numbers.x.to_bytes(32, 'big')), 'y': _b64url(numbers.y.to_bytes(32, 'big'))}
75
+
76
+
77
+ def generate_agent_key(alg: str = 'ES256') -> Dict[str, Dict[str, Any]]:
78
+ """A new key pair for an agent: ES256 (P-256, default) or EdDSA (Ed25519).
79
+ Returns ``{'private_jwk': ..., 'public_jwk': ...}``; kid = the RFC 7638
80
+ thumbprint. Register ``public_jwk``; keep ``private_jwk`` where the agent runs."""
81
+ hashes, serialization, ec, ed25519, _ = _crypto()
82
+ if alg == 'EdDSA':
83
+ key = ed25519.Ed25519PrivateKey.generate()
84
+ d = key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption())
85
+ elif alg == 'ES256':
86
+ key = ec.generate_private_key(ec.SECP256R1())
87
+ d = key.private_numbers().private_value.to_bytes(32, 'big')
88
+ else:
89
+ raise TouchQueConfigException('alg must be ES256 or EdDSA')
90
+ public = _public_jwk(key)
91
+ kid = jwk_thumbprint(public)
92
+ return {
93
+ 'private_jwk': dict(public, d=_b64url(d), kid=kid, alg=alg),
94
+ 'public_jwk': dict(public, kid=kid, alg=alg),
95
+ }
96
+
97
+
98
+ def _load_private_key(value: Union[Dict[str, Any], str, Any]):
99
+ hashes, serialization, ec, ed25519, _ = _crypto()
100
+ try:
101
+ if isinstance(value, str) and value.strip().startswith('{'):
102
+ value = json.loads(value)
103
+ if isinstance(value, dict):
104
+ d = _b64url_decode(value['d'])
105
+ if value.get('kty') == 'OKP' and value.get('crv') == 'Ed25519':
106
+ return ed25519.Ed25519PrivateKey.from_private_bytes(d)
107
+ if value.get('kty') == 'EC' and value.get('crv') == 'P-256':
108
+ return ec.derive_private_key(int.from_bytes(d, 'big'), ec.SECP256R1())
109
+ raise ValueError('unsupported key')
110
+ if isinstance(value, (str, bytes)):
111
+ data = value.encode('utf-8') if isinstance(value, str) else value
112
+ return serialization.load_pem_private_key(data, password=None)
113
+ if isinstance(value, (ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey)):
114
+ return value
115
+ except Exception as exc:
116
+ raise TouchQueConfigException("private_key must be the agent's private key: a JWK (dict or JSON), a PEM string or a cryptography key") from exc
117
+ raise TouchQueConfigException("private_key must be the agent's private key: a JWK (dict or JSON), a PEM string or a cryptography key")
118
+
119
+
120
+ class TouchQueAgent:
121
+ """An AI agent's own TouchQue client (see the module docstring)."""
122
+
123
+ def __init__(self, agent_id: Optional[str] = None, private_key: Union[Dict[str, Any], str, Any, None] = None,
124
+ kid: Optional[str] = None, base_url: Optional[str] = None, scope: Optional[str] = None,
125
+ timeout: int = 10000):
126
+ agent_id = agent_id if agent_id is not None else os.environ.get('TQ_AGENT_ID', '')
127
+ if not _AGENT_ID.match(agent_id or ''):
128
+ raise TouchQueConfigException('Set TQ_AGENT_ID (or pass agent_id): the agt_... id from client.agents.create()')
129
+ key_input = private_key if private_key is not None else os.environ.get('TQ_AGENT_KEY')
130
+ if not key_input:
131
+ raise TouchQueConfigException("Set TQ_AGENT_KEY (or pass private_key): the agent's private key")
132
+ hashes, serialization, ec, ed25519, _ = _crypto()
133
+ self.agent_id = agent_id
134
+ self._key = _load_private_key(key_input)
135
+ if isinstance(self._key, ec.EllipticCurvePrivateKey) and not isinstance(self._key.curve, ec.SECP256R1):
136
+ raise TouchQueConfigException('An EC agent key must be on P-256')
137
+ if not isinstance(self._key, (ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey)):
138
+ raise TouchQueConfigException('The agent key must be EC P-256 or Ed25519')
139
+ self._alg = 'EdDSA' if isinstance(self._key, ed25519.Ed25519PrivateKey) else 'ES256'
140
+ self._public_jwk = _public_jwk(self._key)
141
+ given_kid = None
142
+ if isinstance(key_input, str) and key_input.strip().startswith('{'):
143
+ given_kid = json.loads(key_input).get('kid')
144
+ elif isinstance(key_input, dict):
145
+ given_kid = key_input.get('kid')
146
+ self._kid = kid or given_kid or jwk_thumbprint(self._public_jwk)
147
+ self.base_url = (base_url or os.environ.get('TQ_API_URL') or 'https://api.touchque.com').rstrip('/')
148
+ parsed = urlparse(self.base_url)
149
+ if parsed.scheme != 'https' and not (parsed.scheme == 'http' and (parsed.hostname or '') in _LOCAL_HOSTS):
150
+ raise TouchQueConfigException(f'Refusing a plaintext base_url for "{parsed.hostname}": use https://')
151
+ self._scope = scope
152
+ self._timeout = timeout / 1000.0
153
+ self._token: Optional[Dict[str, Any]] = None
154
+ self._lock = threading.Lock()
155
+
156
+ # ── signing ──────────────────────────────────────────────────────────
157
+
158
+ def _sign(self, header: Dict[str, Any], payload: Dict[str, Any]) -> str:
159
+ hashes, serialization, ec, ed25519, decode_dss_signature = _crypto()
160
+ signing_input = f"{_b64url(_json(dict(header, alg=self._alg)).encode())}.{_b64url(_json(payload).encode())}"
161
+ if self._alg == 'EdDSA':
162
+ sig = self._key.sign(signing_input.encode('ascii'))
163
+ else:
164
+ r, s = decode_dss_signature(self._key.sign(signing_input.encode('ascii'), ec.ECDSA(hashes.SHA256())))
165
+ sig = r.to_bytes(32, 'big') + s.to_bytes(32, 'big')
166
+ return f'{signing_input}.{_b64url(sig)}'
167
+
168
+ def _dpop(self, method: str, url: str, access_token: Optional[str] = None) -> str:
169
+ claims: Dict[str, Any] = {'htm': method, 'htu': url, 'iat': int(time.time()), 'jti': str(uuid.uuid4())}
170
+ if access_token:
171
+ claims['ath'] = _b64url(hashlib.sha256(access_token.encode('ascii')).digest())
172
+ return self._sign({'typ': 'dpop+jwt', 'jwk': self._public_jwk}, claims)
173
+
174
+ @staticmethod
175
+ def _error(response: 'requests.Response') -> TouchQueAPIException:
176
+ try:
177
+ data = response.json()
178
+ except ValueError:
179
+ data = {}
180
+ retry_after = data.get('retry_after', data.get('retryAfter'))
181
+ return api_exception(
182
+ data.get('error_description') or data.get('error') or 'Unknown API Error',
183
+ status=response.status_code,
184
+ code=data.get('code') or data.get('error'),
185
+ reason=data.get('reason'),
186
+ retry_after=retry_after if isinstance(retry_after, int) else None,
187
+ data=data,
188
+ case_code=data.get('caseCode') if isinstance(data.get('caseCode'), str) else None,
189
+ )
190
+
191
+ # ── token ────────────────────────────────────────────────────────────
192
+
193
+ def _fetch_token(self) -> str:
194
+ url = f'{self.base_url}/agents/token'
195
+ now = int(time.time())
196
+ assertion = self._sign({'typ': 'JWT', 'kid': self._kid},
197
+ {'iss': self.agent_id, 'sub': self.agent_id, 'aud': url, 'iat': now, 'exp': now + 60, 'jti': str(uuid.uuid4())})
198
+ form = {'grant_type': 'client_credentials', 'client_assertion_type': AGENT_ASSERTION_TYPE, 'client_assertion': assertion}
199
+ if self._scope:
200
+ form['scope'] = self._scope
201
+ try:
202
+ response = requests.post(url, data=form, headers={'DPoP': self._dpop('POST', url)}, timeout=self._timeout, allow_redirects=False)
203
+ except requests.exceptions.RequestException as e:
204
+ raise TouchQueNetworkException(f'Network Error: {e}') from e
205
+ if response.status_code != 200:
206
+ raise self._error(response)
207
+ data = response.json()
208
+ self._token = {'value': data['access_token'], 'expires_at': time.time() + int(data.get('expires_in', 600))}
209
+ return self._token['value']
210
+
211
+ def access_token(self) -> str:
212
+ """The agent's current token (renewed a minute before it expires)."""
213
+ with self._lock:
214
+ if self._token and self._token['expires_at'] - time.time() > 60:
215
+ return self._token['value']
216
+ return self._fetch_token()
217
+
218
+ def _request(self, method: str, path: str, body: Optional[Dict[str, Any]] = None, retried: bool = False) -> Dict[str, Any]:
219
+ token = self.access_token()
220
+ url = f'{self.base_url}{path}'
221
+ headers = {'Authorization': f'DPoP {token}', 'DPoP': self._dpop(method, url.split('?')[0], token)}
222
+ if body is not None:
223
+ headers['Content-Type'] = 'application/json'
224
+ try:
225
+ response = requests.request(method, url, data=_json(body) if body is not None else None, headers=headers,
226
+ timeout=self._timeout, allow_redirects=False)
227
+ except requests.exceptions.RequestException as e:
228
+ raise TouchQueNetworkException(f'Network Error: {e}') from e
229
+ if response.status_code == 401 and not retried:
230
+ try:
231
+ code = response.json().get('code')
232
+ except ValueError:
233
+ code = None
234
+ if code == 'invalid_token':
235
+ self._token = None
236
+ return self._request(method, path, body, retried=True)
237
+ if response.status_code >= 300:
238
+ raise self._error(response)
239
+ return response.json()
240
+
241
+ def me(self) -> Dict[str, Any]:
242
+ """The agent as TouchQue sees it: its action types, owner, review date and workspace."""
243
+ return self._request('GET', '/agents/me')
244
+
245
+ def request_approval(self, user: str, type: str, authorization_details: List[Dict[str, Any]], reason: Optional[str] = None,
246
+ presence: Optional[str] = None, expires_in: Optional[int] = None, locale: Optional[str] = None,
247
+ reference_id: Optional[str] = None, client_ip: Optional[str] = None,
248
+ user_agent: Optional[str] = None, mandate_id: Optional[str] = None) -> Dict[str, Any]:
249
+ """Asks the person to approve on their TouchQue phone. Show
250
+ ``challengeCode`` in your conversation (presence ``interactive``, the
251
+ default): the person types it on the phone. TouchQue draws the phone's
252
+ rows from ``authorization_details``; ``reason`` is shown as unverified.
253
+ ``requiresPasskey`` (over the policy's requirePasskeyAbove): the person
254
+ approves with a passkey on your page instead, not on the phone."""
255
+ body: Dict[str, Any] = {'externalUsername': user, 'type': type, 'authorizationDetails': authorization_details}
256
+ for key, value in (('reason', reason), ('presence', presence), ('expiresIn', expires_in), ('locale', locale),
257
+ ('referenceId', reference_id), ('clientIp', client_ip), ('userAgent', user_agent), ('mandateId', mandate_id)):
258
+ if value is not None:
259
+ body[key] = value
260
+ return self._request('POST', '/agents/approvals', body)
261
+
262
+ def request_mandate(self, user: str, type: str, max_count: int, max_total: Dict[str, str], per_request: Optional[str] = None,
263
+ recipients: Optional[List[str]] = None, valid_for: Optional[int] = None, reason: Optional[str] = None,
264
+ expires_in: Optional[int] = None, locale: Optional[str] = None) -> Dict[str, Any]:
265
+ """Asks the person for a mandate: one approval on the phone for a bounded
266
+ series of requests (how many, how much in total and per request, to whom,
267
+ until when). Show ``challengeCode``; once ``active``, pass ``mandate_id`` to
268
+ ``request_approval`` and each request within it is approved without a push."""
269
+ body: Dict[str, Any] = {'externalUsername': user, 'type': type, 'maxCount': max_count, 'maxTotal': max_total}
270
+ for key, value in (('perRequest', per_request), ('recipients', recipients), ('validFor', valid_for), ('reason', reason),
271
+ ('expiresIn', expires_in), ('locale', locale)):
272
+ if value is not None:
273
+ body[key] = value
274
+ return self._request('POST', '/agents/mandates', body)
275
+
276
+ def get_connection_token(self, user: str, connection: str, scope: Optional[str] = None) -> Dict[str, Any]:
277
+ """The token vault: a person's own access token at another service, for a connection that
278
+ names this agent (``client.agents.vault``): ``{'access_token', 'token_type': 'Bearer',
279
+ 'expires_in'?, 'scope', 'connection'}``. TouchQue refreshes it; the refresh token never
280
+ leaves. TouchQueAPIError not_connected when nothing is kept for them."""
281
+ body: Dict[str, Any] = {'externalUsername': user, 'connection': connection}
282
+ if scope:
283
+ body['scope'] = scope
284
+ return self._request('POST', '/agents/vault/token', body)
285
+
286
+ def get_mandate(self, mandate_id: str) -> Dict[str, Any]:
287
+ """A mandate's state and what is left."""
288
+ return self._request('GET', f'/agents/mandates/{mandate_id}')
289
+
290
+ def revoke_mandate(self, mandate_id: str) -> Dict[str, Any]:
291
+ """Gives up a mandate (when the task is done early, for example)."""
292
+ return self._request('POST', f'/agents/mandates/{mandate_id}/revoke', {})
293
+
294
+ def get_approval(self, approval_id: str) -> Dict[str, Any]:
295
+ """Where an approval request is; ``approvalToken`` once approved."""
296
+ return self._request('GET', f'/agents/approvals/{approval_id}')
297
+
298
+ def wait_for_approval(self, approval_id: str, timeout: float = 600, interval: float = 2) -> Dict[str, Any]:
299
+ """Polls until the person approves (returns the state holding
300
+ ``approvalToken``). Raises TouchQueApprovalError when it ends otherwise
301
+ and TouchQueTimeoutException after ``timeout`` seconds. An approval given
302
+ during a phone call stays ``provisional`` until the phone confirms it, and
303
+ four eyes stay ``awaiting_second`` until the second person answers."""
304
+ interval = max(0.5, interval)
305
+ end = time.time() + timeout
306
+ while True:
307
+ state = self.get_approval(approval_id)
308
+ if state.get('status') == 'approved' and state.get('approvalToken'):
309
+ return state
310
+ if state.get('status') in _ENDED:
311
+ raise TouchQueApprovalError(approval_id, state['status'], state.get('reason'))
312
+ if time.time() + interval > end:
313
+ raise TouchQueTimeoutException(f"TouchQue: approval '{approval_id}' was not approved within {timeout}s")
314
+ time.sleep(interval)
touchque/agent_card.py ADDED
@@ -0,0 +1,190 @@
1
+ """A2A agent card signatures (A2A 1.0 §8.4).
2
+
3
+ Before your agent trusts another agent's card (its name, URL, skills), check
4
+ that whoever controls its keys signed exactly this card. Each entry of
5
+ ``signatures`` is a JWS (RFC 7515) over the card in RFC 8785 canonical form
6
+ without ``signatures``; the protected header names alg, kid and optionally
7
+ jku (where the keys are)::
8
+
9
+ card = requests.get('https://agent.example/.well-known/agent-card.json').json()
10
+ verify_agent_card(card, card_url='https://agent.example/.well-known/agent-card.json')
11
+
12
+ Keys come from ``keys`` (a JWKS you trust) or from the header's jku, but jku
13
+ is only followed on the card's own host (or a host in ``trusted_jku_hosts``):
14
+ a card that points at keys on a host of its choosing proves nothing. Signers
15
+ must leave out fields at their default values (A2A §8.4.1); the card is
16
+ checked as received.
17
+
18
+ Needs the optional ``cryptography`` package: ``pip install "touchque-authenticator[agent]"``.
19
+ """
20
+
21
+ import json
22
+ from decimal import Decimal
23
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Union
24
+ from urllib.parse import urlparse
25
+
26
+ import requests
27
+
28
+ from .mcp_auth import _b64d, _b64e, _json_part, _public_key, _verify
29
+
30
+ __all__ = ['AgentCardError', 'canonical_json', 'verify_agent_card', 'sign_agent_card']
31
+
32
+ _ALGS = ('RS256', 'ES256', 'EdDSA')
33
+
34
+
35
+ class AgentCardError(Exception):
36
+ """``code``: unsigned | malformed | untrusted_key | invalid_signature | keys_unavailable."""
37
+
38
+ def __init__(self, code: str, message: str):
39
+ super().__init__(message)
40
+ self.code = code
41
+
42
+
43
+ def _es_number(value: float) -> str:
44
+ """A number as ECMAScript's Number::toString writes it (RFC 8785 §3.2.2.3)."""
45
+ if value != value or value in (float('inf'), float('-inf')):
46
+ raise AgentCardError('malformed', 'a number is not finite')
47
+ if value == 0:
48
+ return '0'
49
+ sign = '-' if value < 0 else ''
50
+ t = Decimal(repr(abs(value))).normalize().as_tuple()
51
+ digits = ''.join(str(d) for d in t.digits)
52
+ k = len(digits)
53
+ n = t.exponent + k
54
+ if k <= n <= 21:
55
+ out = digits + '0' * (n - k)
56
+ elif 0 < n <= 21:
57
+ out = digits[:n] + '.' + digits[n:]
58
+ elif -6 < n <= 0:
59
+ out = '0.' + '0' * (-n) + digits
60
+ else:
61
+ e = n - 1
62
+ out = digits[0] + ('.' + digits[1:] if k > 1 else '') + 'e' + ('+' if e >= 0 else '-') + str(abs(e))
63
+ return sign + out
64
+
65
+
66
+ def canonical_json(value: Any) -> str:
67
+ """RFC 8785 (JSON Canonicalization Scheme): sorted keys (UTF-16 order), no whitespace, ECMAScript numbers."""
68
+ if value is None:
69
+ return 'null'
70
+ if value is True:
71
+ return 'true'
72
+ if value is False:
73
+ return 'false'
74
+ if isinstance(value, str):
75
+ return json.dumps(value, ensure_ascii=False)
76
+ if isinstance(value, int):
77
+ return str(value)
78
+ if isinstance(value, float):
79
+ return _es_number(value)
80
+ if isinstance(value, (list, tuple)):
81
+ return '[' + ','.join(canonical_json(v) for v in value) + ']'
82
+ if isinstance(value, Mapping):
83
+ keys = sorted(value.keys(), key=lambda k: k.encode('utf-16-be'))
84
+ return '{' + ','.join(json.dumps(k, ensure_ascii=False) + ':' + canonical_json(value[k]) for k in keys) + '}'
85
+ raise AgentCardError('malformed', f'cannot canonicalize {type(value).__name__}')
86
+
87
+
88
+ def _payload(card: Mapping[str, Any]) -> str:
89
+ rest = {k: v for k, v in card.items() if k != 'signatures'}
90
+ return _b64e(canonical_json(rest).encode('utf-8'))
91
+
92
+
93
+ def _host_of(url: Optional[str]) -> Optional[str]:
94
+ if not url:
95
+ return None
96
+ u = urlparse(url)
97
+ local = u.hostname in ('localhost', '127.0.0.1', '::1')
98
+ if u.scheme != 'https' and not (u.scheme == 'http' and local):
99
+ return None
100
+ return u.netloc
101
+
102
+
103
+ def verify_agent_card(card: Union[Mapping[str, Any], str], card_url: Optional[str] = None,
104
+ keys: Union[Iterable[Mapping[str, Any]], Mapping[str, Any], None] = None,
105
+ trusted_jku_hosts: Optional[List[str]] = None) -> Dict[str, str]:
106
+ """Checks the card's signatures: ``{'kid', 'alg', 'key_source': 'keys' | 'jku'}`` or AgentCardError."""
107
+ try:
108
+ c = json.loads(card) if isinstance(card, str) else card
109
+ except ValueError:
110
+ raise AgentCardError('malformed', 'the card is not JSON')
111
+ if not isinstance(c, Mapping):
112
+ raise AgentCardError('malformed', 'the card is not a JSON object')
113
+ sigs = c.get('signatures')
114
+ if not isinstance(sigs, list) or not sigs:
115
+ raise AgentCardError('unsigned', 'the card has no signatures')
116
+ given = list((keys.get('keys') or []) if isinstance(keys, Mapping) else (keys or []))
117
+ card_host = _host_of(card_url)
118
+ payload = _payload(c)
119
+ last = AgentCardError('invalid_signature', 'no signature verifies')
120
+ for s in sigs:
121
+ if not isinstance(s, Mapping) or not isinstance(s.get('protected'), str) or not isinstance(s.get('signature'), str):
122
+ continue
123
+ try:
124
+ head = _json_part(s['protected'])
125
+ except ValueError:
126
+ continue
127
+ alg = head.get('alg')
128
+ if alg not in _ALGS or not isinstance(head.get('kid'), str) or 'crit' in head:
129
+ continue
130
+ if 'typ' in head and head['typ'] != 'JOSE':
131
+ continue
132
+ jwk = next((k for k in given if k.get('kid') == head['kid']), None)
133
+ source = 'keys'
134
+ if jwk is None and isinstance(head.get('jku'), str):
135
+ jku_host = _host_of(head['jku'])
136
+ if not jku_host or (jku_host != card_host and jku_host not in (trusted_jku_hosts or [])):
137
+ last = AgentCardError('untrusted_key', f"jku {head['jku']} is not on the card's host")
138
+ continue
139
+ try:
140
+ r = requests.get(head['jku'], timeout=5, allow_redirects=False)
141
+ if r.status_code != 200:
142
+ raise RuntimeError(f'it answered {r.status_code}')
143
+ jwk = next((k for k in r.json().get('keys', []) if k.get('kid') == head['kid']), None)
144
+ except Exception as err:
145
+ last = AgentCardError('keys_unavailable', f"the keys at {head['jku']} could not be read: {err}")
146
+ continue
147
+ source = 'jku'
148
+ if jwk is None:
149
+ if last.code == 'invalid_signature':
150
+ last = AgentCardError('untrusted_key', f"no trusted key {head['kid']}")
151
+ continue
152
+ try:
153
+ key = _public_key(jwk)
154
+ except (ValueError, KeyError):
155
+ continue
156
+ try:
157
+ sig = _b64d(s['signature'])
158
+ except ValueError: # not base64url (e.g. non-ASCII): a signature that does not verify
159
+ sig = b''
160
+ if _verify(f"{s['protected']}.{payload}".encode(), sig, alg, key):
161
+ return {'kid': head['kid'], 'alg': alg, 'key_source': source}
162
+ last = AgentCardError('invalid_signature', 'the signature does not match the card')
163
+ raise last
164
+
165
+
166
+ def sign_agent_card(card: Mapping[str, Any], private_key, kid: str, jku: Optional[str] = None) -> Dict[str, Any]:
167
+ """Signs a card you publish: returns it with one more entry in ``signatures`` (EC P-256, Ed25519 or RSA key)."""
168
+ from cryptography.hazmat.primitives import hashes
169
+ from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa
170
+ from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
171
+ if isinstance(private_key, ec.EllipticCurvePrivateKey):
172
+ alg = 'ES256'
173
+ elif isinstance(private_key, ed25519.Ed25519PrivateKey):
174
+ alg = 'EdDSA'
175
+ elif isinstance(private_key, rsa.RSAPrivateKey):
176
+ alg = 'RS256'
177
+ else:
178
+ raise AgentCardError('malformed', 'the key must be EC P-256, Ed25519 or RSA')
179
+ header = {'alg': alg, 'typ': 'JOSE', 'kid': kid, **({'jku': jku} if jku else {})}
180
+ protected = _b64e(json.dumps(header, separators=(',', ':')).encode())
181
+ data = f'{protected}.{_payload(card)}'.encode()
182
+ if alg == 'ES256':
183
+ r, s = decode_dss_signature(private_key.sign(data, ec.ECDSA(hashes.SHA256())))
184
+ sig = r.to_bytes(32, 'big') + s.to_bytes(32, 'big')
185
+ elif alg == 'EdDSA':
186
+ sig = private_key.sign(data)
187
+ else:
188
+ sig = private_key.sign(data, padding.PKCS1v15(), hashes.SHA256())
189
+ prior = list(card.get('signatures') or [])
190
+ return {**card, 'signatures': prior + [{'protected': protected, 'signature': _b64e(sig)}]}