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.
- touchque/__init__.py +148 -0
- touchque/agent.py +314 -0
- touchque/agent_card.py +190 -0
- touchque/agent_frameworks.py +215 -0
- touchque/approval_proof.py +104 -0
- touchque/approval_token.py +134 -0
- touchque/client.py +105 -0
- touchque/code_reply.py +75 -0
- touchque/config.py +56 -0
- touchque/contrib/__init__.py +4 -0
- touchque/contrib/django.py +175 -0
- touchque/contrib/fastapi.py +208 -0
- touchque/contrib/flask.py +154 -0
- touchque/cross_app_access.py +222 -0
- touchque/exceptions.py +170 -0
- touchque/guard.py +290 -0
- touchque/hosted_login.py +474 -0
- touchque/http_client.py +156 -0
- touchque/mcp_auth.py +256 -0
- touchque/my_account.py +173 -0
- touchque/org_agent.py +262 -0
- touchque/rate_limiter.py +56 -0
- touchque/relay.py +643 -0
- touchque/resources/__init__.py +1 -0
- touchque/resources/action_types.py +52 -0
- touchque/resources/actions.py +234 -0
- touchque/resources/agents.py +236 -0
- touchque/resources/assist.py +115 -0
- touchque/resources/auth.py +65 -0
- touchque/resources/device_risk.py +174 -0
- touchque/resources/identity.py +268 -0
- touchque/resources/login.py +296 -0
- touchque/resources/mobile.py +130 -0
- touchque/resources/offline.py +138 -0
- touchque/resources/qr_pairings.py +93 -0
- touchque/resources/recovery.py +118 -0
- touchque/resources/risk.py +141 -0
- touchque/resources/safe_guard.py +99 -0
- touchque/resources/webauthn.py +115 -0
- touchque/resources/webhook.py +258 -0
- touchque/safe_guard.py +308 -0
- touchque/security_events.py +205 -0
- touchque/steps.py +239 -0
- touchque_authenticator-4.2.0.dist-info/METADATA +630 -0
- touchque_authenticator-4.2.0.dist-info/RECORD +48 -0
- touchque_authenticator-4.2.0.dist-info/WHEEL +5 -0
- touchque_authenticator-4.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|