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
@@ -0,0 +1,215 @@
1
+ """A person approves an AI agent's tool call on their phone before it runs, in
2
+ whichever agent framework the agent is built with.
3
+
4
+ One rule, an ``ApprovalPolicy``, says who approves (``user``), as which action
5
+ type, and what the phone shows (``details``: RFC 9396 authorization details
6
+ drawn from the tool's arguments). TouchQue draws the rows, the person types the
7
+ number the conversation shows and confirms with Face ID, and the call runs only
8
+ once they approved. The approval token (single use, bound to exactly what was
9
+ approved) is there for the service that executes: ``current_approval()`` inside
10
+ the tool, or the MCP request's ``_meta``::
11
+
12
+ policy = ApprovalPolicy(agent, user='ada@acme.com', type='PAY_INVOICE',
13
+ details=lambda args, tool: [{'type': 'payment_initiation',
14
+ 'instructedAmount': {'currency': 'TRY', 'amount': args['amount']},
15
+ 'creditorAccount': {'iban': args['iban']}}],
16
+ on_challenge=lambda state, tool: reply(f"Type {state['challengeCode']} in the TouchQue app."))
17
+
18
+ @with_touchque_approval(policy)
19
+ def pay(iban: str, amount: str) -> str:
20
+ return bank.pay(iban, amount, current_approval()['approvalToken'])
21
+
22
+ The decorated function keeps its signature, so LangChain's ``@tool``,
23
+ LlamaIndex's ``FunctionTool.from_defaults`` and the OpenAI Agents SDK's
24
+ ``@function_tool`` read the same arguments (async functions work too). For
25
+ frameworks with their own approval step: ``approve_interruptions_with_touchque``
26
+ (OpenAI Agents SDK), ``touchque_can_use_tool`` (Claude Agent SDK) and
27
+ ``guard_mcp_session`` (an MCP ClientSession).
28
+ """
29
+
30
+ import asyncio
31
+ import contextvars
32
+ import functools
33
+ import inspect
34
+ import json
35
+ from typing import Any, Callable, Dict, List, Optional, Union
36
+
37
+ from .exceptions import TouchQueApprovalError, TouchQueTimeoutException
38
+
39
+ _current: contextvars.ContextVar = contextvars.ContextVar('touchque_approval', default=None)
40
+
41
+ Pick = Union[str, Callable[[Dict[str, Any], str], str]]
42
+
43
+
44
+ class ApprovalPolicy:
45
+ """Who approves which tool calls, and what their phone shows."""
46
+
47
+ def __init__(self, agent, user: Pick, type: Pick, details: Callable[[Dict[str, Any], str], List[Dict[str, Any]]],
48
+ reason: Optional[Pick] = None, presence: Optional[str] = None, locale: Optional[str] = None,
49
+ mandate_id: Optional[str] = None, on_challenge: Optional[Callable[[Dict[str, Any], str], Any]] = None,
50
+ timeout: float = 300):
51
+ self.agent = agent
52
+ self.user = user
53
+ self.type = type
54
+ self.details = details
55
+ self.reason = reason
56
+ self.presence = presence
57
+ self.locale = locale
58
+ self.mandate_id = mandate_id
59
+ self.on_challenge = on_challenge
60
+ self.timeout = timeout
61
+
62
+
63
+ async def _to_thread(fn, *args):
64
+ """``asyncio.to_thread`` (Python 3.9+), also on 3.7 and 3.8: the blocking wait for the phone
65
+ runs in the loop's default executor, with this context's variables."""
66
+ if hasattr(asyncio, 'to_thread'):
67
+ return await asyncio.to_thread(fn, *args)
68
+ ctx = contextvars.copy_context()
69
+ return await asyncio.get_running_loop().run_in_executor(None, functools.partial(ctx.run, fn, *args))
70
+
71
+
72
+ def current_approval() -> Optional[Dict[str, Any]]:
73
+ """Inside a tool wrapped by ``with_touchque_approval``: the approval it runs under."""
74
+ return _current.get()
75
+
76
+
77
+ def _pick(value, args: Dict[str, Any], tool: str):
78
+ return value(args, tool) if callable(value) else value
79
+
80
+
81
+ def require_agent_approval(policy: ApprovalPolicy, args: Dict[str, Any], tool: str = 'tool') -> Dict[str, Any]:
82
+ """Asks the person's phone and waits: the approved state (with ``approvalToken``),
83
+ or TouchQueApprovalError (rejected, blocked, expired...) / TouchQueTimeoutException."""
84
+ asked = policy.agent.request_approval(
85
+ _pick(policy.user, args, tool), _pick(policy.type, args, tool), policy.details(args, tool),
86
+ reason=_pick(policy.reason, args, tool) if policy.reason is not None else None,
87
+ presence=policy.presence, locale=policy.locale, mandate_id=policy.mandate_id,
88
+ )
89
+ if asked.get('status') == 'approved' and asked.get('approvalToken'):
90
+ return asked
91
+ if asked.get('challengeCode') and policy.on_challenge:
92
+ policy.on_challenge(asked, tool)
93
+ return policy.agent.wait_for_approval(asked['approvalId'], timeout=policy.timeout)
94
+
95
+
96
+ def _bound_args(fn, args, kwargs) -> Dict[str, Any]:
97
+ try:
98
+ bound = inspect.signature(fn).bind_partial(*args, **kwargs)
99
+ return dict(bound.arguments)
100
+ except (TypeError, ValueError):
101
+ return dict(kwargs)
102
+
103
+
104
+ def with_touchque_approval(policy: ApprovalPolicy, tool: Optional[str] = None):
105
+ """Decorator: the function runs only after the person approved on the phone;
106
+ ``current_approval()`` is set while it runs. Works on plain and async functions."""
107
+
108
+ def wrap(fn):
109
+ name = tool or fn.__name__
110
+ if inspect.iscoroutinefunction(fn):
111
+ @functools.wraps(fn)
112
+ async def run_async(*args, **kwargs):
113
+ approval = await _to_thread(require_agent_approval, policy, _bound_args(fn, args, kwargs), name)
114
+ token = _current.set(approval)
115
+ try:
116
+ return await fn(*args, **kwargs)
117
+ finally:
118
+ _current.reset(token)
119
+ return run_async
120
+
121
+ @functools.wraps(fn)
122
+ def run(*args, **kwargs):
123
+ approval = require_agent_approval(policy, _bound_args(fn, args, kwargs), name)
124
+ token = _current.set(approval)
125
+ try:
126
+ return fn(*args, **kwargs)
127
+ finally:
128
+ _current.reset(token)
129
+ return run
130
+
131
+ return wrap
132
+
133
+
134
+ def _args_of(raw) -> Dict[str, Any]:
135
+ if isinstance(raw, str):
136
+ try:
137
+ return json.loads(raw)
138
+ except ValueError:
139
+ return {}
140
+ return raw or {}
141
+
142
+
143
+ def approve_interruptions_with_touchque(result, policies: Dict[str, ApprovalPolicy]):
144
+ """OpenAI Agents SDK: tools with ``needs_approval`` interrupt the run; this asks the
145
+ phone for each interruption in ``policies`` (by tool name) and approves or rejects it
146
+ on the run state. Returns the state to resume with ``Runner.run(agent, state)``."""
147
+ state = result.to_state() if hasattr(result, 'to_state') else result.state
148
+ for item in getattr(result, 'interruptions', None) or []:
149
+ raw = getattr(item, 'raw_item', None) or getattr(item, 'rawItem', None) or item
150
+ name = getattr(raw, 'name', None) or (raw.get('name') if isinstance(raw, dict) else None)
151
+ policy = policies.get(name)
152
+ if not policy:
153
+ continue
154
+ arguments = getattr(raw, 'arguments', None) if not isinstance(raw, dict) else raw.get('arguments')
155
+ try:
156
+ require_agent_approval(policy, _args_of(arguments), name)
157
+ state.approve(item)
158
+ except (TouchQueApprovalError, TouchQueTimeoutException):
159
+ state.reject(item)
160
+ return state
161
+
162
+
163
+ def touchque_can_use_tool(policies: Dict[str, ApprovalPolicy], token_field: Optional[str] = None, deny_others: bool = False):
164
+ """Claude Agent SDK: a ``can_use_tool`` callback. Tools with a policy run only after
165
+ the phone approved (``token_field``: put the approval token into the tool's input);
166
+ others are allowed or, with ``deny_others``, denied."""
167
+ try:
168
+ from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny # type: ignore
169
+ except ImportError: # the SDK's own result types when installed, plain dicts otherwise
170
+ PermissionResultAllow = PermissionResultDeny = None
171
+
172
+ def allow(updated):
173
+ return PermissionResultAllow(updated_input=updated) if PermissionResultAllow else {'behavior': 'allow', 'updatedInput': updated}
174
+
175
+ def deny(message):
176
+ return PermissionResultDeny(message=message) if PermissionResultDeny else {'behavior': 'deny', 'message': message}
177
+
178
+ async def can_use_tool(tool_name: str, input_data: Dict[str, Any], context: Any = None):
179
+ policy = policies.get(tool_name)
180
+ if not policy:
181
+ return deny(f'{tool_name} is not allowed for this agent') if deny_others else allow(input_data)
182
+ try:
183
+ approval = await _to_thread(require_agent_approval, policy, input_data, tool_name)
184
+ except (TouchQueApprovalError, TouchQueTimeoutException) as err:
185
+ return deny(f"The person did not approve {tool_name} on their phone ({getattr(err, 'status', 'timeout')})")
186
+ return allow({**input_data, token_field: approval.get('approvalToken')} if token_field else input_data)
187
+
188
+ return can_use_tool
189
+
190
+
191
+ class _GuardedSession:
192
+ def __init__(self, session, policies: Dict[str, ApprovalPolicy]):
193
+ self._session = session
194
+ self._policies = policies
195
+
196
+ def __getattr__(self, name):
197
+ return getattr(self._session, name)
198
+
199
+ async def call_tool(self, name: str, arguments: Optional[Dict[str, Any]] = None, *args, **kwargs):
200
+ policy = self._policies.get(name)
201
+ if policy:
202
+ approval = await _to_thread(require_agent_approval, policy, arguments or {}, name)
203
+ meta = dict(kwargs.pop('meta', None) or {})
204
+ meta['touchque/approvalToken'] = approval.get('approvalToken')
205
+ try:
206
+ return await self._session.call_tool(name, arguments, *args, meta=meta, **kwargs)
207
+ except TypeError: # an MCP SDK too old to send _meta: the call still waited for the phone
208
+ return await self._session.call_tool(name, arguments, *args, **kwargs)
209
+ return await self._session.call_tool(name, arguments, *args, **kwargs)
210
+
211
+
212
+ def guard_mcp_session(session, policies: Dict[str, ApprovalPolicy]):
213
+ """An MCP ``ClientSession``: the tools in ``policies`` are called only after the phone
214
+ approved; the approval token goes to the server in ``_meta`` ("touchque/approvalToken")."""
215
+ return _GuardedSession(session, policies)
@@ -0,0 +1,104 @@
1
+ """Verifies the device-signed proof of an approval ("what you see is what you sign").
2
+
3
+ When the user's phone holds a hardware key (Secure Enclave / Android Keystore),
4
+ every approval is signed by it over the request id, the action type, the
5
+ number-match code and a digest of the transaction details the user was shown.
6
+ The proof arrives in the SUCCESS webhook (``approvalProof``) and in
7
+ ``login.status(id)['approvalProof']``. Verifying it does not require trusting
8
+ TouchQue: it proves the enrolled phone signed exactly these details.
9
+
10
+ Needs the optional ``cryptography`` package:
11
+ ``pip install "touchque-authenticator[proof]"``.
12
+ """
13
+ import base64
14
+ import binascii
15
+ from typing import Any, Mapping, Optional
16
+
17
+ from .steps import LoginDetails, details_digest
18
+
19
+ __all__ = ['verify_approval_proof', 'details_digest']
20
+
21
+ _MESSAGE_PREFIX = 'touchque-approve:v2'
22
+
23
+
24
+ def _load_crypto():
25
+ try:
26
+ from cryptography.exceptions import InvalidSignature
27
+ from cryptography.hazmat.primitives import hashes
28
+ from cryptography.hazmat.primitives.asymmetric import ec
29
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
30
+ raise ImportError(
31
+ 'verify_approval_proof needs the "cryptography" package: '
32
+ 'pip install "touchque-authenticator[proof]"'
33
+ ) from exc
34
+ return InvalidSignature, hashes, ec
35
+
36
+
37
+ def verify_approval_proof(
38
+ proof: Optional[Mapping[str, Any]],
39
+ *,
40
+ request_id: str,
41
+ type: str,
42
+ details: Optional[LoginDetails] = None,
43
+ challenge_code: Optional[str] = None,
44
+ expected_public_key: Optional[str] = None,
45
+ allow_unpinned_key: bool = False,
46
+ ) -> bool:
47
+ """Returns True only if ``proof`` was signed by the device key over exactly
48
+ the request, action type, challenge code and details you expect.
49
+
50
+ :param proof: the ``approvalProof`` object from the webhook / status.
51
+ :param request_id: the request id ``login.request()`` returned.
52
+ :param type: the action type you sent (e.g. ``'WITHDRAW'``).
53
+ :param details: the details you sent, exactly as you sent them (dict or
54
+ list of ``{label, value}``); ``None`` when you sent none.
55
+ :param challenge_code: the number-match code ``login.request()`` returned,
56
+ when number matching was used.
57
+ :param expected_public_key: pin the device — the base64 public key you
58
+ stored for this user (e.g. ``proof['publicKey']`` of their first
59
+ verified approval). A proof from any other key is rejected. Required
60
+ unless ``allow_unpinned_key`` is set.
61
+ :param allow_unpinned_key: accept whatever key the proof carries. Without a
62
+ pinned key the proof only shows that *some* key signed these details.
63
+ Only for trust-on-first-use: store ``proof['publicKey']`` after this
64
+ first check and pin it from then on.
65
+
66
+ Fails closed (no pin and no ``allow_unpinned_key`` -> False) and never
67
+ raises for malformed input; it returns False.
68
+ """
69
+ InvalidSignature, hashes, ec = _load_crypto()
70
+ try:
71
+ if not isinstance(proof, Mapping):
72
+ return False
73
+ if proof.get('version') != 'v2' or proof.get('algorithm') != 'ECDSA-P256-SHA256':
74
+ return False
75
+ public_key_b64 = proof.get('publicKey')
76
+ message = proof.get('message')
77
+ signature_b64 = proof.get('signature')
78
+ if not all(isinstance(v, str) for v in (public_key_b64, message, signature_b64)):
79
+ return False
80
+ if expected_public_key:
81
+ if public_key_b64 != expected_public_key:
82
+ return False
83
+ elif allow_unpinned_key is not True:
84
+ return False
85
+
86
+ expected_message = '\n'.join([
87
+ _MESSAGE_PREFIX,
88
+ str(request_id),
89
+ str(type),
90
+ challenge_code if challenge_code is not None else '',
91
+ details_digest(details),
92
+ ])
93
+ if message != expected_message:
94
+ return False
95
+
96
+ raw = base64.b64decode(public_key_b64, validate=True)
97
+ if len(raw) != 65 or raw[0] != 0x04:
98
+ return False
99
+ key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), raw)
100
+ signature = base64.b64decode(signature_b64, validate=True)
101
+ key.verify(signature, message.encode('utf-8'), ec.ECDSA(hashes.SHA256()))
102
+ return True
103
+ except (InvalidSignature, ValueError, TypeError, binascii.Error):
104
+ return False
@@ -0,0 +1,134 @@
1
+ """What a partner's server checks before executing what an AI agent asked
2
+ for: the approval token's claims match the transaction (person, action type,
3
+ authorization details), and, offline, its ES256 signature against TouchQue's
4
+ keys (GET /agents/jwks.json).
5
+
6
+ Matching rules (fixtures/agent-vectors.json): every expected field must be
7
+ equal, extra fields are ignored; amounts compare as decimals ("500" =
8
+ "500.00"), IBANs without spaces and in any case, currencies in any case; a
9
+ dict matches a token with exactly one entry, a list matches entry by entry
10
+ with the same length.
11
+ """
12
+ import base64
13
+ import json
14
+ import re
15
+ import time
16
+ from typing import Any, Dict, List, Mapping, Optional, Union
17
+
18
+ from .exceptions import TouchQueApprovalMismatchError
19
+
20
+ __all__ = ['matches_authorization_details', 'assert_approval_matches', 'decode_approval_token', 'verify_approval_token_with']
21
+
22
+ Expected = Union[Mapping[str, Any], List[Mapping[str, Any]]]
23
+
24
+
25
+ def _decimal(value: Any) -> Optional[str]:
26
+ text = str(value).strip()
27
+ if not re.match(r'^\d+(\.\d+)?$', text):
28
+ return None
29
+ whole, _, frac = text.partition('.')
30
+ whole = whole.lstrip('0') or '0'
31
+ frac = frac.rstrip('0')
32
+ return f'{whole}.{frac}' if frac else whole
33
+
34
+
35
+ def _same(key: str, actual: Any, expected: Any) -> bool:
36
+ if isinstance(expected, (dict, list)):
37
+ return _partial(actual, expected)
38
+ if key == 'amount':
39
+ return _decimal(actual) is not None and _decimal(actual) == _decimal(expected)
40
+ if key in ('iban', 'currency'):
41
+ return isinstance(actual, str) and re.sub(r'\s+', '', actual).upper() == re.sub(r'\s+', '', str(expected)).upper()
42
+ return actual == expected and type(actual) is type(expected)
43
+
44
+
45
+ def _partial(actual: Any, expected: Any) -> bool:
46
+ if isinstance(expected, list):
47
+ return isinstance(actual, list) and len(actual) == len(expected) and all(
48
+ _partial(a, e) if isinstance(e, dict) else a == e for a, e in zip(actual, expected))
49
+ if not isinstance(actual, dict):
50
+ return False
51
+ return all(k in actual and _same(k, actual[k], v) for k, v in expected.items())
52
+
53
+
54
+ def matches_authorization_details(details: Any, expected: Expected) -> bool:
55
+ """Does ``details`` (a token's authorization_details) say what ``expected`` says?"""
56
+ if not isinstance(details, list):
57
+ return False
58
+ if isinstance(expected, list):
59
+ return len(details) == len(expected) and all(_partial(d, e) for d, e in zip(details, expected))
60
+ return len(details) == 1 and _partial(details[0], expected)
61
+
62
+
63
+ def assert_approval_matches(claims: Mapping[str, Any], user: Optional[str] = None, type: Optional[str] = None,
64
+ authorization_details: Optional[Expected] = None) -> None:
65
+ """Raises TouchQueApprovalMismatchError unless the claims say what you expect."""
66
+ if user is not None and claims.get('sub') != user:
67
+ raise TouchQueApprovalMismatchError("the approval is for another person")
68
+ if type is not None and claims.get('type') != type:
69
+ raise TouchQueApprovalMismatchError(f"the approval is for another action type ({claims.get('type')})")
70
+ if authorization_details is not None and not matches_authorization_details(claims.get('authorization_details'), authorization_details):
71
+ raise TouchQueApprovalMismatchError('the approved details differ from what you are about to execute')
72
+
73
+
74
+ def _b64(part: str) -> bytes:
75
+ return base64.urlsafe_b64decode(part + '=' * (-len(part) % 4))
76
+
77
+
78
+ def decode_approval_token(token: str) -> Dict[str, Any]:
79
+ """``{'header': ..., 'claims': ...}`` WITHOUT checking the signature."""
80
+ parts = token.split('.') if isinstance(token, str) else []
81
+ if len(parts) != 3:
82
+ raise TouchQueApprovalMismatchError('not an approval token')
83
+ try:
84
+ return {'header': json.loads(_b64(parts[0])), 'claims': json.loads(_b64(parts[1]))}
85
+ except (ValueError, TypeError) as exc:
86
+ raise TouchQueApprovalMismatchError('not an approval token') from exc
87
+
88
+
89
+ def verify_approval_token_with(token: str, jwks: Mapping[str, Any], audience: Optional[str] = None,
90
+ now: Optional[float] = None, **expect: Any) -> Dict[str, Any]:
91
+ """Verifies an approval token offline: typ touchque-approval+jwt, ES256 by
92
+ a key of ``jwks``, not expired (30 s leeway), ``aud`` equal to
93
+ ``audience``, then ``user`` / ``type`` / ``authorization_details``.
94
+ ``audience`` is required: ``/agents/jwks.json`` is one key set for every
95
+ Touchque workspace, so without it a token Touchque signed for another
96
+ workspace would pass. Pass your own (``client.agents.approval_audience()``,
97
+ ``touchque:workspace:<id>``), or use ``client.agents.verify_approval_token``,
98
+ which fills it in. Does NOT make it single-use
99
+ (``client.agents.redeem_approval`` does). Needs ``cryptography``."""
100
+ if not isinstance(audience, str) or not audience:
101
+ raise TouchQueApprovalMismatchError("audience is required: your workspace's touchque:workspace:<id> (client.agents.approval_audience())")
102
+ from cryptography.exceptions import InvalidSignature
103
+ from cryptography.hazmat.primitives import hashes
104
+ from cryptography.hazmat.primitives.asymmetric import ec
105
+ from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
106
+
107
+ decoded = decode_approval_token(token)
108
+ header, claims = decoded['header'], decoded['claims']
109
+ if header.get('typ') != 'touchque-approval+jwt' or header.get('alg') != 'ES256':
110
+ raise TouchQueApprovalMismatchError('not an approval token (typ/alg)')
111
+ jwk = next((k for k in jwks.get('keys', []) if k.get('kid') == header.get('kid') and k.get('kty') == 'EC' and k.get('crv') == 'P-256'), None)
112
+ if not jwk:
113
+ raise TouchQueApprovalMismatchError('signed by an unknown key')
114
+ h, p, s = token.split('.')
115
+ try:
116
+ sig = _b64(s)
117
+ except ValueError: # not base64url (e.g. non-ASCII): a signature that does not verify
118
+ sig = b''
119
+ if len(sig) != 64:
120
+ raise TouchQueApprovalMismatchError('the signature does not verify')
121
+ key = ec.EllipticCurvePublicNumbers(int.from_bytes(_b64(jwk['x']), 'big'), int.from_bytes(_b64(jwk['y']), 'big'), ec.SECP256R1()).public_key()
122
+ try:
123
+ key.verify(encode_dss_signature(int.from_bytes(sig[:32], 'big'), int.from_bytes(sig[32:], 'big')), f'{h}.{p}'.encode('ascii'), ec.ECDSA(hashes.SHA256()))
124
+ except InvalidSignature as exc:
125
+ raise TouchQueApprovalMismatchError('the signature does not verify') from exc
126
+ current = time.time() if now is None else now
127
+ if not isinstance(claims.get('exp'), (int, float)) or claims['exp'] + 30 < current:
128
+ raise TouchQueApprovalMismatchError('the approval token expired')
129
+ if not isinstance(claims.get('aud'), str) or not claims.get('aud'):
130
+ raise TouchQueApprovalMismatchError('the approval token names no workspace')
131
+ if claims['aud'] != audience:
132
+ raise TouchQueApprovalMismatchError('the approval token is for another workspace')
133
+ assert_approval_matches(claims, **expect)
134
+ return claims
touchque/client.py ADDED
@@ -0,0 +1,105 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from .config import Config
4
+ from .http_client import HttpClient
5
+ from .resources.action_types import ActionTypes
6
+ from .resources.actions import Actions
7
+ from .resources.agents import Agents
8
+ from .resources.identity import Identity
9
+ from .resources.auth import Auth
10
+ from .resources.login import Login
11
+ from .resources.offline import Offline
12
+ from .resources.risk import Risk
13
+ from .resources.recovery import Recovery
14
+ from .resources.mobile import Mobile
15
+ from .resources.qr_pairings import QrPairings
16
+ from .resources.device_risk import DeviceRisk
17
+ from .resources.safe_guard import SafeGuard
18
+ from .resources.assist import Assist
19
+ from .resources.webauthn import WebAuthn
20
+ from .resources.webhook import Webhook
21
+ from .steps import LoginDetails, check as _check, complete as _complete, start as _start
22
+
23
+
24
+ class TouchQue:
25
+ """
26
+ TouchQue SDK client.
27
+
28
+ # .env: TQ_API_KEY=tq_... TQ_API_SECRET=...
29
+ client = TouchQue() # from the environment
30
+ client = TouchQue(Config(api_key=..., api_secret=...)) # explicit
31
+
32
+ One line per protected route (see ``touchque.contrib`` for Django /
33
+ FastAPI / Flask adapters, or call ``client.start`` / ``client.check`` /
34
+ ``client.complete`` yourself for any framework).
35
+ """
36
+
37
+ def __init__(self, config: Optional[Config] = None):
38
+ self.config = config or Config()
39
+ self._api_secret = self.config.api_secret
40
+ http = HttpClient(self.config)
41
+
42
+ self.auth = Auth(http)
43
+ self.login = Login(http)
44
+ self.webauthn = WebAuthn(http)
45
+ self.webhook = Webhook(self.config)
46
+ self.offline = Offline(http)
47
+ #: Action types: what users approve (LOGIN, SEND_MONEY…). Was ``actions`` before 4.0.
48
+ self.action_types = ActionTypes(http)
49
+ #: TouchQue Actions: your own code in the approval pipeline.
50
+ self.actions = Actions(http)
51
+ #: Silent sign-ins: should this sign-in ask the phone, and which browsers TouchQue knows.
52
+ self.risk = Risk(http)
53
+ #: Never Locked Out: move a user whose phone is gone to a new phone, with colleagues vouching.
54
+ self.recovery = Recovery(http)
55
+ #: Help desk: IT working on a person's account in the open, and checks on their phone before sensitive actions.
56
+ self.assist = Assist(http)
57
+ #: AI agents as identities of their own: register (public key), suspend, revoke, kill switch.
58
+ #: The agent itself uses ``touchque.TouchQueAgent``.
59
+ self.agents = Agents(http)
60
+ # TouchQue Login Management API: customers' accounts, roles, organizations, apps
61
+ self.identity = Identity(http)
62
+ #: Mobile SDK: your own iOS/Android app approves for your users (enrollment tokens, devices, settings).
63
+ self.mobile = Mobile(http)
64
+ #: Pairing a browser by QR code: the phone that scans it is the one asked to approve.
65
+ self.qr_pairings = QrPairings(http)
66
+ #: Device risk: the device on your sign-up, reset, sign-in or approval page (a paid add-on).
67
+ self.device_risk = DeviceRisk(http)
68
+ #: Safe Guard: Safe transactions approved on the approver's phone, for TouchQueSafeGuard.
69
+ self.safe_guard = SafeGuard(http)
70
+
71
+ # ── headless step-up (see touchque.steps / touchque.guard) ─────────────
72
+
73
+ def start(self, action: str, user: str, details: Optional[LoginDetails] = None,
74
+ reference_id: Optional[str] = None, ip: Optional[str] = None,
75
+ user_agent: Optional[str] = None, browser_proof: Optional[str] = None) -> Dict[str, Any]:
76
+ """Starts an approval and returns immediately — show the step in your
77
+ UI. ``waiting`` + ``number``: show the number, the user picks it on
78
+ the phone. ``enroll``: show ``enroll['qrCodeDataUrl']`` so the user
79
+ links the TouchQue app first. ``browser_proof``: the browser's
80
+ ``X-TouchQue-Browser`` proof, so the approval makes the browser known
81
+ (silent sign-ins, see ``risk.check``)."""
82
+ return _start(self, action, user, details=details, reference_id=reference_id, ip=ip, user_agent=user_agent,
83
+ browser_proof=browser_proof)
84
+
85
+ def check(self, request_id: str) -> Dict[str, Any]:
86
+ """Where a started approval is now: waiting, approved, rejected, expired…"""
87
+ return _check(self, request_id)
88
+
89
+ def complete(self, request_id: str, user: str, action: str, details: Optional[LoginDetails] = None,
90
+ reference_id: Optional[str] = None) -> Dict[str, Any]:
91
+ """Uses an approved request exactly once, after checking it is for
92
+ this user, action and transaction. Call it right before doing the
93
+ protected thing."""
94
+ return _complete(self, request_id, user, action, details=details, reference_id=reference_id)
95
+
96
+
97
+ _default_client: Optional[TouchQue] = None
98
+
99
+
100
+ def get_default_client() -> TouchQue:
101
+ """The client built from TQ_API_KEY / TQ_API_SECRET, created on first use."""
102
+ global _default_client
103
+ if _default_client is None:
104
+ _default_client = TouchQue()
105
+ return _default_client
touchque/code_reply.py ADDED
@@ -0,0 +1,75 @@
1
+ """A code the person types back on the phone (``login.request(reply=...)``).
2
+
3
+ A sign-in code another service sent them (a text message, WhatsApp, e-mail). Each request gets
4
+ a one-time P-256 key pair; its public key goes with the request, the phone encrypts what was
5
+ typed to it, and only you can open it: TouchQue stores and hands back nothing but the
6
+ ciphertext (``login.status(request_id)['reply']['ct']``).
7
+
8
+ ECDH P-256, HKDF-SHA256 (salt: the request id, info ``touchque-code-reply-v1``), AES-256-GCM
9
+ (additional data ``touchque-code-reply:<request id>``), as
10
+ ``v1.<the phone's one-time key, x9.63>.<nonce|ciphertext|tag>``, base64url.
11
+
12
+ Needs the optional ``cryptography`` package: ``pip install "touchque-authenticator[proof]"``.
13
+ """
14
+ import base64
15
+ import re
16
+ from typing import Dict, Optional
17
+
18
+ from .exceptions import TouchQueConfigException
19
+
20
+ _INFO = b'touchque-code-reply-v1'
21
+ _CT_RE = re.compile(r'^v1\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$')
22
+ _CODE_RE = re.compile(r'^[A-Za-z0-9 -]{4,20}$')
23
+
24
+
25
+ def _b64(data: bytes) -> str:
26
+ return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii')
27
+
28
+
29
+ def _unb64(text: str) -> bytes:
30
+ return base64.urlsafe_b64decode(text + '=' * (-len(text) % 4))
31
+
32
+
33
+ def _crypto():
34
+ try:
35
+ from cryptography.hazmat.primitives import hashes, serialization
36
+ from cryptography.hazmat.primitives.asymmetric import ec
37
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
38
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
39
+ except ImportError as e: # pragma: no cover - depends on the environment
40
+ raise TouchQueConfigException('Code replies need the cryptography package: pip install "touchque-authenticator[proof]"') from e
41
+ return hashes, serialization, ec, AESGCM, HKDF
42
+
43
+
44
+ def new_code_reply_key() -> Dict[str, str]:
45
+ """A one-time key pair for one request: send ``public_key`` as ``reply['key']``, keep ``private_key``.
46
+
47
+ ``public_key``: the uncompressed P-256 point (65 bytes), base64url. ``private_key``: PKCS#8 DER,
48
+ base64url; keep it server-side, for this one request.
49
+ """
50
+ hashes, serialization, ec, _, _ = _crypto()
51
+ key = ec.generate_private_key(ec.SECP256R1())
52
+ public = key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
53
+ private = key.private_bytes(serialization.Encoding.DER, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
54
+ return {'public_key': _b64(public), 'private_key': _b64(private)}
55
+
56
+
57
+ def open_code_reply(private_key: str, request_id: str, ct: str) -> Optional[str]:
58
+ """The code the person typed, or ``None`` when ``ct`` does not open with this key for this request."""
59
+ hashes, serialization, ec, AESGCM, HKDF = _crypto()
60
+ try:
61
+ m = _CT_RE.match(str(ct or ''))
62
+ if not m:
63
+ return None
64
+ epk, box = _unb64(m.group(1)), _unb64(m.group(2))
65
+ if len(epk) != 65 or epk[0] != 4 or len(box) < 12 + 16 + 1:
66
+ return None
67
+ priv = serialization.load_der_private_key(_unb64(private_key), password=None)
68
+ peer = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), epk)
69
+ shared = priv.exchange(ec.ECDH(), peer)
70
+ key = HKDF(algorithm=hashes.SHA256(), length=32, salt=str(request_id).encode('utf-8'), info=_INFO).derive(shared)
71
+ aad = ('touchque-code-reply:%s' % request_id).encode('utf-8')
72
+ code = AESGCM(key).decrypt(box[:12], box[12:], aad).decode('utf-8').strip()
73
+ return code if _CODE_RE.match(code) else None
74
+ except Exception: # noqa: BLE001 - anything wrong is "does not open"
75
+ return None