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,258 @@
1
+ import json
2
+ import time
3
+ import hmac
4
+ import hashlib
5
+ import threading
6
+ from datetime import datetime
7
+ from typing import Any, Dict, Optional
8
+
9
+ from ..config import Config
10
+ from ..exceptions import TouchQueWebhookReplayException, TouchQueWebhookSignatureException
11
+
12
+ # Default replay window: reject a callback whose signed `timestamp` is more
13
+ # than this many seconds away from now. Matches the server's replay guard.
14
+ DEFAULT_TOLERANCE_SECONDS = 300
15
+
16
+
17
+ class WebhookReasons:
18
+ """``reason`` values of a REJECTED login webhook. Treat a value you do not
19
+ know as a rejection: new ones are added in minor versions."""
20
+ USER_REJECTED = 'user_rejected'
21
+ #: The user said "this wasn't me": 2FA is frozen.
22
+ USER_REPORTED_FRAUD = 'user_reported_fraud'
23
+ #: The user said someone on the phone told them to approve: 2FA is frozen; treat it as a takeover in progress.
24
+ USER_REPORTED_COACHED = 'user_reported_coached'
25
+ #: An AI agent's request: the user said no and blocked the agent from asking them again (no 2FA freeze).
26
+ USER_BLOCKED_AGENT = 'user_blocked_agent'
27
+ RISK_REVERSED = 'risk_reversed'
28
+ RISK_STEP_UP = 'risk_step_up'
29
+ APPROVAL_SIGNATURE_INVALID = 'approval_signature_invalid'
30
+ DEVICE_INTEGRITY_FAILED = 'device_integrity_failed'
31
+ #: Vishing Shield: approved during a call (or with the screen shared / remote-controlled), critical, no passkey.
32
+ VISHING_SUSPECTED = 'vishing_suspected'
33
+ #: Mirror Alarm: the sign-in page was not on your site (a real-time phishing proxy).
34
+ PHISHING_ORIGIN = 'phishing_origin'
35
+
36
+
37
+ class WebhookEvents:
38
+ """``event`` values of TouchQue's event webhooks. Ignore types you do not know."""
39
+ DEVICE_LINKED = 'DEVICE_LINKED'
40
+ DEVICE_UNLINKED = 'DEVICE_UNLINKED'
41
+ PASSKEY_REGISTERED = 'PASSKEY_REGISTERED'
42
+ PASSKEY_DELETED = 'PASSKEY_DELETED'
43
+ #: Device risk: an identification or a device_risk.check raised a signal that asks for more. Never a trait.
44
+ DEVICE_RISK_SIGNAL = 'DEVICE_RISK_SIGNAL'
45
+ #: Device risk: an alert rule of the workspace went off.
46
+ DEVICE_RISK_ALERT = 'DEVICE_RISK_ALERT'
47
+ #: After the call, the user confirmed a provisional approval was theirs: critical actions are allowed again.
48
+ APPROVAL_RECONFIRMED = 'APPROVAL_RECONFIRMED'
49
+ #: The user took an approval back (``reason``: ``COACHED`` or ``UNDO``): end the session built on it.
50
+ APPROVAL_REVOKED = 'APPROVAL_REVOKED'
51
+ # AI agents (client.agents): changes partners may act on, and what TouchQue noticed.
52
+ #: An AI agent was registered.
53
+ AGENT_CREATED = 'AGENT_CREATED'
54
+ #: An AI agent was suspended (through the API or the dashboard): its tokens stopped working.
55
+ AGENT_SUSPENDED = 'AGENT_SUSPENDED'
56
+ #: An AI agent may act again.
57
+ AGENT_RESUMED = 'AGENT_RESUMED'
58
+ #: An AI agent was revoked for good.
59
+ AGENT_REVOKED = 'AGENT_REVOKED'
60
+ #: A key was added to an AI agent.
61
+ AGENT_KEY_ADDED = 'AGENT_KEY_ADDED'
62
+ #: A key was removed from an AI agent; tokens bound to it stopped working.
63
+ AGENT_KEY_REMOVED = 'AGENT_KEY_REMOVED'
64
+ #: The kill switch stopped every AI agent of the workspace.
65
+ AGENTS_PAUSED = 'AGENTS_PAUSED'
66
+ #: The kill switch was turned off.
67
+ AGENTS_RESUMED = 'AGENTS_RESUMED'
68
+ #: An AI agent's policy changed.
69
+ AGENT_POLICY_CHANGED = 'AGENT_POLICY_CHANGED'
70
+ AGENT_WORKSPACE_POLICY_CHANGED = 'AGENT_WORKSPACE_POLICY_CHANGED'
71
+ #: An approval token was used.
72
+ AGENT_APPROVAL_REDEEMED = 'AGENT_APPROVAL_REDEEMED'
73
+ #: A person stopped an AI agent from asking them ("this wasn't me", coached, or three rejections).
74
+ AGENT_BLOCKED_BY_PERSON = 'AGENT_BLOCKED_BY_PERSON'
75
+ #: An admin let an AI agent ask a person again.
76
+ AGENT_UNBLOCKED_FOR_PERSON = 'AGENT_UNBLOCKED_FOR_PERSON'
77
+ #: An AI agent sent the same request to the same person three times in 10 minutes.
78
+ AGENT_LOOP_DETECTED = 'AGENT_LOOP_DETECTED'
79
+ #: An AI agent asked a person for a mandate.
80
+ AGENT_MANDATE_REQUESTED = 'AGENT_MANDATE_REQUESTED'
81
+ #: A mandate was ended.
82
+ AGENT_MANDATE_REVOKED = 'AGENT_MANDATE_REVOKED'
83
+ #: An AI agent's text carried an instruction to the model or a link that carries data; the request was refused.
84
+ AGENT_INJECTION_SUSPECTED = 'AGENT_INJECTION_SUSPECTED'
85
+ #: An approval came back sooner than a person can answer; it is never usable.
86
+ AGENT_INHUMAN_APPROVAL = 'AGENT_INHUMAN_APPROVAL'
87
+ #: A new or renamed AI agent's name read like another agent's; it was refused.
88
+ AGENT_LOOKALIKE_NAME = 'AGENT_LOOKALIKE_NAME'
89
+ #: Mobile SDK: send this push with your own keys (``mobile_push_from_webhook``).
90
+ MOBILE_PUSH = 'MOBILE_PUSH'
91
+ # Never Locked Out (client.recovery): every step of a recovery.
92
+ #: A recovery started; the vouchers were asked.
93
+ RECOVERY_STARTED = 'RECOVERY_STARTED'
94
+ #: The new phone was bound to the recovery.
95
+ RECOVERY_DEVICE_BOUND = 'RECOVERY_DEVICE_BOUND'
96
+ #: The vouchers' quorum was reached: the account moves at ``coolOffUntil`` unless vetoed.
97
+ RECOVERY_COOLING = 'RECOVERY_COOLING'
98
+ #: The account moved to the new phone (``method``: ``vouch`` or ``old_phone``); ``DEVICE_LINKED`` follows.
99
+ RECOVERY_COMPLETED = 'RECOVERY_COMPLETED'
100
+ #: The recovery was stopped (the old phone, an admin or a voucher).
101
+ RECOVERY_VETOED = 'RECOVERY_VETOED'
102
+ #: The recovery ended unused (``reason``: ``not_bound`` in time, or ``replaced`` by a new one).
103
+ RECOVERY_EXPIRED = 'RECOVERY_EXPIRED'
104
+ #: The recovery ended without a move (``reason``, e.g. ``not_enough_approvals``).
105
+ RECOVERY_FAILED = 'RECOVERY_FAILED'
106
+ # Help desk (client.assist): IT working on a person's account.
107
+ #: A help desk agent started working on the account.
108
+ ASSIST_OPENED = 'ASSIST_OPENED'
109
+ #: The help desk session ended.
110
+ ASSIST_ENDED = 'ASSIST_ENDED'
111
+ #: The person confirmed the help desk's check on their phone.
112
+ ASSIST_CHECK_PASSED = 'ASSIST_CHECK_PASSED'
113
+ #: The check failed (``outcome``: ``wrong_number``, ``not_me``, ``coached``, ``declined``): page someone.
114
+ ASSIST_CHECK_FAILED = 'ASSIST_CHECK_FAILED'
115
+ #: The help desk lifted the person's approval freeze after a check on the phone.
116
+ ASSIST_UNFROZEN = 'ASSIST_UNFROZEN'
117
+ #: The person checked that the agent from IT is really working on their account.
118
+ ASSIST_CLAIM_CONFIRMED = 'ASSIST_CLAIM_CONFIRMED'
119
+ #: Nobody from IT was working on the account: someone is pretending to be IT. No ``assistId``; page someone.
120
+ ASSIST_CLAIM_UNVERIFIED = 'ASSIST_CLAIM_UNVERIFIED'
121
+
122
+
123
+ class ReplayCache:
124
+ """
125
+ Storage for webhook ``jti`` values already accepted. ``check_and_set``
126
+ must atomically record ``jti`` for ``ttl_seconds`` and return ``True`` if
127
+ it was NOT seen before. Subclass it (or duck-type it) over Redis / your
128
+ database when you run several processes.
129
+ """
130
+
131
+ def check_and_set(self, jti: str, ttl_seconds: int) -> bool:
132
+ raise NotImplementedError
133
+
134
+
135
+ class MemoryReplayCache(ReplayCache):
136
+ """In-process :class:`ReplayCache`. Entries expire; the dict is capped. Thread-safe."""
137
+
138
+ def __init__(self, max_entries: int = 100_000):
139
+ self._seen: Dict[str, float] = {}
140
+ self._max = max_entries
141
+ self._lock = threading.Lock()
142
+
143
+ def check_and_set(self, jti: str, ttl_seconds: int) -> bool:
144
+ now = time.monotonic()
145
+ with self._lock:
146
+ expires = self._seen.get(jti)
147
+ if expires is not None and expires > now:
148
+ return False
149
+ if len(self._seen) >= self._max:
150
+ for key in [k for k, exp in self._seen.items() if exp <= now]:
151
+ del self._seen[key]
152
+ while len(self._seen) >= self._max:
153
+ self._seen.pop(next(iter(self._seen)))
154
+ self._seen[jti] = now + ttl_seconds
155
+ return True
156
+
157
+
158
+ def _canonicalize(payload: Dict[str, Any]) -> str:
159
+ """
160
+ Reproduce the server's ``stableStringify``:
161
+ drop the ``signature`` field, sort the TOP-LEVEL keys, and serialize
162
+ with no whitespace. Nested values keep their order, exactly as
163
+ ``JSON.stringify`` leaves them.
164
+ """
165
+ without_sig = {k: v for k, v in payload.items() if k != "signature"}
166
+ ordered = {k: without_sig[k] for k in sorted(without_sig)}
167
+ return json.dumps(ordered, separators=(",", ":"), ensure_ascii=False)
168
+
169
+
170
+ def _parse_iso_timestamp(value: Any) -> Optional[float]:
171
+ if not isinstance(value, str):
172
+ return None
173
+ try:
174
+ text = value.replace("Z", "+00:00")
175
+ return datetime.fromisoformat(text).timestamp()
176
+ except ValueError:
177
+ return None
178
+
179
+
180
+ class Webhook:
181
+ def __init__(self, config: Config):
182
+ self._config = config
183
+
184
+ def verify(
185
+ self,
186
+ raw_body: str,
187
+ signature: Optional[str] = None,
188
+ tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
189
+ replay_cache: Optional[ReplayCache] = None,
190
+ ) -> Dict[str, Any]:
191
+ """
192
+ Verify the signature of an incoming TouchQue webhook and return the
193
+ decoded payload.
194
+
195
+ Args:
196
+ raw_body: The raw request body as text, exactly as received (do
197
+ not re-serialize it), e.g. Flask's
198
+ ``request.get_data(as_text=True)``.
199
+ signature: The ``x-signature`` header value. Optional — if omitted,
200
+ the ``signature`` field carried inside the body is used.
201
+ tolerance_seconds: Reject a callback whose signed ``timestamp`` is
202
+ missing, invalid or further than this from now (replay
203
+ protection). Pass ``0`` to disable the check.
204
+ replay_cache: Optional :class:`ReplayCache` (e.g. a shared
205
+ :class:`MemoryReplayCache`). A second delivery of the same
206
+ ``jti`` raises :class:`TouchQueWebhookReplayException`.
207
+
208
+ Raises:
209
+ TouchQueWebhookSignatureException: signature missing, malformed
210
+ body, signature mismatch, or a stale callback.
211
+ TouchQueWebhookReplayException: this ``jti`` was already accepted
212
+ (a subclass of the above, so existing handlers still reject it).
213
+ """
214
+ if not isinstance(raw_body, str):
215
+ raise TouchQueWebhookSignatureException("raw_body must be a string")
216
+
217
+ try:
218
+ payload = json.loads(raw_body)
219
+ except json.JSONDecodeError:
220
+ raise TouchQueWebhookSignatureException("Invalid JSON payload")
221
+ if not isinstance(payload, dict):
222
+ raise TouchQueWebhookSignatureException("Webhook payload must be a JSON object")
223
+
224
+ provided = signature or payload.get("signature")
225
+ if not isinstance(provided, str) or not provided:
226
+ raise TouchQueWebhookSignatureException("No signature provided")
227
+
228
+ mac = hmac.new(
229
+ self._config.api_secret.encode("utf-8"),
230
+ _canonicalize(payload).encode("utf-8"),
231
+ hashlib.sha256,
232
+ )
233
+ # Do NOT put the expected signature in the exception — a caller that
234
+ # surfaces the error text would hand an attacker a signing oracle.
235
+ # Compared as bytes: compare_digest raises TypeError for a str with non-ASCII text.
236
+ if not hmac.compare_digest(mac.hexdigest().encode("ascii"), provided.encode("utf-8")):
237
+ raise TouchQueWebhookSignatureException("Invalid webhook signature")
238
+
239
+ # TouchQue always signs a timestamp, so a missing or unparseable one
240
+ # fails closed instead of skipping the freshness check.
241
+ if tolerance_seconds and tolerance_seconds > 0:
242
+ ts = _parse_iso_timestamp(payload.get("timestamp"))
243
+ if ts is None:
244
+ raise TouchQueWebhookSignatureException("Webhook timestamp is missing or invalid")
245
+ if abs(time.time() - ts) > tolerance_seconds:
246
+ raise TouchQueWebhookSignatureException("Webhook timestamp is outside the allowed window")
247
+
248
+ if replay_cache is not None:
249
+ jti = payload.get("jti")
250
+ if not isinstance(jti, str) or not jti:
251
+ raise TouchQueWebhookSignatureException("Webhook jti is missing")
252
+ # Remember it for twice the window so it outlives any timestamp
253
+ # that could still pass the freshness check.
254
+ ttl = max((tolerance_seconds or 0) * 2, 600)
255
+ if not replay_cache.check_and_set(jti, ttl):
256
+ raise TouchQueWebhookReplayException(jti)
257
+
258
+ return payload
touchque/safe_guard.py ADDED
@@ -0,0 +1,308 @@
1
+ """Safe Guard approvals, checked without trusting TouchQue.
2
+
3
+ The approver's phone decodes the raw Safe transaction itself and signs, with a P-256 key only
4
+ its biometrics unlock, a 105-byte approval that TouchQueSafeGuard checks on chain::
5
+
6
+ index (1) | expiresAt (8) | displayDigest (32) | r (32) | s (32)
7
+
8
+ over the SHA-256 of the 149-byte message::
9
+
10
+ "TQG1" | purpose (1) | chainId (32) | guard (20) | Safe (20) | subject (32) | expiresAt (8) | displayDigest (32)
11
+
12
+ where subject is the Safe transaction hash (EIP-712, purpose 1 ``approve``) or, for a change the
13
+ guard schedules, the governance hash (purpose 2 ``schedule``). ``verify_safe_approval`` makes the
14
+ guard's check off chain: both hashes are computed here from the raw fields, so a co-signer (or
15
+ your own server) refuses an approval of anything else, whoever relayed it.
16
+
17
+ The hashes need nothing else; ``verify_safe_approval`` needs the optional ``cryptography``
18
+ package: ``pip install "touchque-authenticator[proof]"``.
19
+ """
20
+ import datetime
21
+ import hashlib
22
+ import re
23
+ import time
24
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Union
25
+
26
+ from .exceptions import TouchQueConfigException
27
+
28
+ __all__ = [
29
+ 'safe_tx_hash', 'safe_governance_hash', 'safe_approval_message', 'safe_display_digest', 'verify_safe_approval',
30
+ 'with_safe_approvals', 'safe_schedule_call',
31
+ ]
32
+
33
+ _MAGIC = b'TQG1'
34
+ _PURPOSES = {'approve': 1, 'schedule': 2}
35
+ # The guard (MAX_APPROVAL_TTL) refuses an approval valid for longer than a day.
36
+ _MAX_APPROVAL_TTL = 86400
37
+ # OpenZeppelin P256 (the guard) refuses s above N/2.
38
+ _HALF_N = 0x7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8
39
+ _MAX_UINT256 = (1 << 256) - 1
40
+ _MAX_UINT64 = (1 << 64) - 1
41
+ _ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
42
+ _ADDRESS = re.compile(r'^0x[0-9a-fA-F]{40}$')
43
+ _HEX = re.compile(r'^0x(?:[0-9a-fA-F]{2})*$')
44
+ _APPROVAL = re.compile(r'^(?:0x)?[0-9a-fA-F]{210}$')
45
+ _KEY = re.compile(r'^[0-9a-fA-F]{128}$')
46
+
47
+ # ── Keccak-256 (the Ethereum hash: Keccak padding, not SHA3-256) ──
48
+ _RC = [int(h, 16) for h in (
49
+ '0000000000000001', '0000000000008082', '800000000000808a', '8000000080008000', '000000000000808b', '0000000080000001',
50
+ '8000000080008081', '8000000000008009', '000000000000008a', '0000000000000088', '0000000080008009', '000000008000000a',
51
+ '000000008000808b', '800000000000008b', '8000000000008089', '8000000000008003', '8000000000008002', '8000000000000080',
52
+ '000000000000800a', '800000008000000a', '8000000080008081', '8000000000008080', '0000000080000001', '8000000080008008',
53
+ )]
54
+ # Rotation of lane x + 5y.
55
+ _ROT = (0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14)
56
+ _MASK = (1 << 64) - 1
57
+
58
+
59
+ def _rotl(v: int, n: int) -> int:
60
+ return ((v << n) | (v >> (64 - n))) & _MASK if n else v
61
+
62
+
63
+ def _keccak_f(a: List[int]) -> List[int]:
64
+ for rc in _RC:
65
+ c = [a[x] ^ a[x + 5] ^ a[x + 10] ^ a[x + 15] ^ a[x + 20] for x in range(5)]
66
+ d = [c[(x + 4) % 5] ^ _rotl(c[(x + 1) % 5], 1) for x in range(5)]
67
+ a = [a[i] ^ d[i % 5] for i in range(25)]
68
+ b = [0] * 25
69
+ for x in range(5):
70
+ for y in range(5):
71
+ b[y + 5 * ((2 * x + 3 * y) % 5)] = _rotl(a[x + 5 * y], _ROT[x + 5 * y])
72
+ a = [b[i] ^ (~b[i - i % 5 + (i + 1) % 5] & b[i - i % 5 + (i + 2) % 5]) for i in range(25)]
73
+ a[0] ^= rc
74
+ return a
75
+
76
+
77
+ def _keccak256(data: bytes) -> bytes:
78
+ rate = 136
79
+ padded = bytearray(data) + bytearray(rate - len(data) % rate)
80
+ padded[len(data)] ^= 0x01
81
+ padded[-1] ^= 0x80
82
+ a = [0] * 25
83
+ for off in range(0, len(padded), rate):
84
+ for k in range(rate // 8):
85
+ a[k] ^= int.from_bytes(padded[off + 8 * k:off + 8 * k + 8], 'little')
86
+ a = _keccak_f(a)
87
+ return b''.join(a[k].to_bytes(8, 'little') for k in range(4))
88
+
89
+
90
+ _DOMAIN_TYPEHASH = _keccak256(b'EIP712Domain(uint256 chainId,address verifyingContract)')
91
+ _SAFE_TX_TYPEHASH = _keccak256(
92
+ b'SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,'
93
+ b'address gasToken,address refundReceiver,uint256 nonce)')
94
+ _GOVERNANCE_TYPEHASH = _keccak256(b'TouchQueGovernance(uint256 chainId,address safe,address to,uint256 value,bytes32 dataHash,uint8 operation)')
95
+ _SCHEDULE_SELECTOR = _keccak256(b'schedule(address,bytes32,bytes)')[:4]
96
+
97
+
98
+ # ── Field parsing: the partner's own data, so a wrong field raises ──
99
+ def _uint(value: Any, field: str, top: int = _MAX_UINT256) -> int:
100
+ v = None
101
+ if isinstance(value, int) and not isinstance(value, bool):
102
+ v = value
103
+ elif isinstance(value, str) and re.match(r'^(0|[1-9][0-9]*)$', value):
104
+ v = int(value)
105
+ if v is None or v < 0 or v > top:
106
+ raise TouchQueConfigException(f'safe_guard: {field} must be a non-negative integer (a decimal string for large values).')
107
+ return v
108
+
109
+
110
+ def _address(value: Any, field: str) -> bytes:
111
+ if not isinstance(value, str) or not _ADDRESS.match(value):
112
+ raise TouchQueConfigException(f'safe_guard: {field} must be an address (0x and 40 hex characters).')
113
+ return bytes.fromhex(value[2:])
114
+
115
+
116
+ def _hex(value: Any, field: str, size: Optional[int] = None) -> bytes:
117
+ if not isinstance(value, str) or not _HEX.match(value) or (size is not None and len(value) != 2 + 2 * size):
118
+ raise TouchQueConfigException(f'safe_guard: {field} must be 0x-prefixed hex' + (f' of {size} bytes.' if size is not None else '.'))
119
+ return bytes.fromhex(value[2:])
120
+
121
+
122
+ def _strip0x(s: str) -> str:
123
+ return s[2:] if s.startswith('0x') else s
124
+
125
+
126
+ def _word(v: int) -> bytes:
127
+ return v.to_bytes(32, 'big')
128
+
129
+
130
+ def _pad(b: bytes) -> bytes:
131
+ return bytes(32 - len(b)) + b
132
+
133
+
134
+ def _target(target: Mapping[str, Any]) -> Dict[str, Any]:
135
+ tx = target.get('tx') if isinstance(target, Mapping) else None
136
+ if not isinstance(tx, Mapping):
137
+ raise TouchQueConfigException('safe_guard: pass {"chainId", "safe", "guard", "tx": {"to", "value", "data", "operation", ..., "nonce"}}.')
138
+ operation = tx.get('operation', 0)
139
+ if operation not in (0, 1) or isinstance(operation, bool):
140
+ raise TouchQueConfigException('safe_guard: tx.operation must be 0 (call) or 1 (delegatecall).')
141
+ return {
142
+ 'chainId': _uint(target.get('chainId'), 'chainId', _MAX_UINT64),
143
+ 'safe': _address(target.get('safe'), 'safe'),
144
+ 'guard': _address(target.get('guard'), 'guard'),
145
+ 'to': _address(tx.get('to'), 'tx.to'),
146
+ 'value': _uint(tx.get('value', 0), 'tx.value'),
147
+ 'data': _hex(tx.get('data', '0x'), 'tx.data'),
148
+ 'operation': operation,
149
+ 'safeTxGas': _uint(tx.get('safeTxGas', 0), 'tx.safeTxGas'),
150
+ 'baseGas': _uint(tx.get('baseGas', 0), 'tx.baseGas'),
151
+ 'gasPrice': _uint(tx.get('gasPrice', 0), 'tx.gasPrice'),
152
+ 'gasToken': _address(tx.get('gasToken', _ZERO_ADDRESS), 'tx.gasToken'),
153
+ 'refundReceiver': _address(tx.get('refundReceiver', _ZERO_ADDRESS), 'tx.refundReceiver'),
154
+ 'nonce': _uint(tx.get('nonce'), 'tx.nonce'),
155
+ }
156
+
157
+
158
+ def safe_tx_hash(target: Mapping[str, Any]) -> str:
159
+ """The Safe transaction hash (EIP-712, domain = chainId + the Safe): what the owners sign and an ordinary approval covers."""
160
+ t = _target(target)
161
+ domain = _keccak256(_DOMAIN_TYPEHASH + _word(t['chainId']) + _pad(t['safe']))
162
+ struct = _keccak256(
163
+ _SAFE_TX_TYPEHASH + _pad(t['to']) + _word(t['value']) + _keccak256(t['data']) + _word(t['operation'])
164
+ + _word(t['safeTxGas']) + _word(t['baseGas']) + _word(t['gasPrice']) + _pad(t['gasToken']) + _pad(t['refundReceiver'])
165
+ + _word(t['nonce']))
166
+ return '0x' + _keccak256(b'\x19\x01' + domain + struct).hex()
167
+
168
+
169
+ def safe_governance_hash(target: Mapping[str, Any]) -> str:
170
+ """What the guard schedules a governance change under: the change itself, without the nonce or gas fields."""
171
+ t = _target(target)
172
+ return '0x' + _keccak256(
173
+ _GOVERNANCE_TYPEHASH + _word(t['chainId']) + _pad(t['safe']) + _pad(t['to']) + _word(t['value'])
174
+ + _keccak256(t['data']) + _word(t['operation'])).hex()
175
+
176
+
177
+ def safe_approval_message(purpose: str, chain_id: Union[int, str], guard: str, safe: str, subject: str,
178
+ expires_at: Union[int, str], display_digest: str) -> str:
179
+ """The 149-byte message the phone signs (its SHA-256 is what the guard verifies), as 0x hex."""
180
+ if purpose not in _PURPOSES:
181
+ raise TouchQueConfigException("safe_guard: purpose is 'approve' or 'schedule'.")
182
+ return '0x' + (
183
+ _MAGIC + bytes([_PURPOSES[purpose]]) + _word(_uint(chain_id, 'chainId', _MAX_UINT64)) + _address(guard, 'guard')
184
+ + _address(safe, 'safe') + _hex(subject, 'subject', 32) + _uint(expires_at, 'expiresAt', _MAX_UINT64).to_bytes(8, 'big')
185
+ + _hex(display_digest, 'displayDigest', 32)).hex()
186
+
187
+
188
+ def safe_display_digest(rows: Iterable[Mapping[str, str]]) -> str:
189
+ """SHA-256 of the rows as "label\\x1Fvalue" joined by "\\x1E" (UTF-8), as 0x hex: the digest an approval carries."""
190
+ canonical = '\x1e'.join(f"{r['label']}\x1f{r['value']}" for r in rows)
191
+ return '0x' + hashlib.sha256(canonical.encode('utf-8')).hexdigest()
192
+
193
+
194
+ def _public_key(key: Union[str, Mapping[str, str]]):
195
+ if isinstance(key, Mapping):
196
+ xy = _strip0x(str(key.get('x', ''))) + _strip0x(str(key.get('y', '')))
197
+ else:
198
+ xy = _strip0x(str(key))
199
+ if not _KEY.match(xy):
200
+ return None
201
+ try:
202
+ from cryptography.hazmat.primitives.asymmetric import ec
203
+ except ImportError as e: # pragma: no cover - depends on the environment
204
+ raise TouchQueConfigException('Safe Guard approvals need the cryptography package: pip install "touchque-authenticator[proof]"') from e
205
+ try:
206
+ return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), b'\x04' + bytes.fromhex(xy))
207
+ except ValueError:
208
+ return None
209
+
210
+
211
+ def _seconds(now: Union[None, int, float, datetime.datetime]) -> int:
212
+ if now is None:
213
+ return int(time.time())
214
+ if isinstance(now, datetime.datetime):
215
+ return int(now.timestamp())
216
+ return int(now)
217
+
218
+
219
+ def verify_safe_approval(target: Mapping[str, Any], approval: str, public_key: Union[str, Mapping[str, str]], *,
220
+ purpose: Optional[str] = None, approver_index: Optional[int] = None,
221
+ display_digest: Optional[str] = None, rows: Optional[Iterable[Mapping[str, str]]] = None,
222
+ now: Union[None, int, float, datetime.datetime] = None) -> Dict[str, Any]:
223
+ """Checks a phone's Safe Guard approval the way TouchQueSafeGuard does, without trusting the server that relayed it.
224
+
225
+ The Safe and governance hashes come from ``target`` (``{"chainId", "safe", "guard", "tx"}``),
226
+ the key from the guard's approver list on chain (``x || y``, 128 hex characters, or
227
+ ``{"x", "y"}``). ``purpose`` (``approve`` or ``schedule``): the one you expect; without it,
228
+ only ``approve`` (this exact transaction, to execute) is accepted, so name ``schedule`` to
229
+ check an approval that schedules a governance change. ``approver_index``: the approval
230
+ must name it. ``display_digest`` or ``rows``: from your own decoding, the rows the phone must
231
+ have shown. ``now``: a datetime or Unix seconds.
232
+
233
+ Returns ``{"valid", "reason", "purpose", "approverIndex", "expiresAt", "displayDigest",
234
+ "safeTxHash", "governanceHash"}``; ``reason`` is ``None`` when valid, else one of
235
+ ``approval_malformed``, ``public_key_invalid``, ``approver_index_mismatch``,
236
+ ``approval_expired``, ``rows_differ``, ``high_s``, ``signature_invalid``. Raises only when
237
+ ``target`` is not a Safe transaction::
238
+
239
+ status = tq.safe_guard.status(request_id)
240
+ check = verify_safe_approval(my_tx, status['safeTx']['approval'], approver_key_from_chain, approver_index=0)
241
+ if not check['valid']:
242
+ raise RuntimeError(f"approval refused: {check['reason']}")
243
+ """
244
+ t = _target(target)
245
+ hashes = {'safeTxHash': safe_tx_hash(target), 'governanceHash': safe_governance_hash(target)}
246
+ parsed: Dict[str, Any] = {'approverIndex': None, 'expiresAt': None, 'displayDigest': None}
247
+
248
+ def refuse(reason: str) -> Dict[str, Any]:
249
+ return {'valid': False, 'reason': reason, 'purpose': None, **parsed, **hashes}
250
+
251
+ if not isinstance(approval, str) or not _APPROVAL.match(approval):
252
+ return refuse('approval_malformed')
253
+ b = bytes.fromhex(_strip0x(approval))
254
+ expires_at = int.from_bytes(b[1:9], 'big')
255
+ parsed = {'approverIndex': b[0], 'expiresAt': expires_at, 'displayDigest': '0x' + b[9:41].hex()}
256
+ key = _public_key(public_key)
257
+ if key is None:
258
+ return refuse('public_key_invalid')
259
+ if approver_index is not None and approver_index != b[0]:
260
+ return refuse('approver_index_mismatch')
261
+ seconds = _seconds(now)
262
+ if expires_at < seconds or expires_at > seconds + _MAX_APPROVAL_TTL:
263
+ return refuse('approval_expired')
264
+ expected = display_digest if display_digest is not None else (safe_display_digest(rows) if rows is not None else None)
265
+ if expected is not None and '0x' + _strip0x(expected.lower()) != parsed['displayDigest']:
266
+ return refuse('rows_differ')
267
+ s = int.from_bytes(b[73:105], 'big')
268
+ if s > _HALF_N:
269
+ return refuse('high_s')
270
+
271
+ from cryptography.exceptions import InvalidSignature
272
+ from cryptography.hazmat.primitives import hashes as crypto_hashes
273
+ from cryptography.hazmat.primitives.asymmetric import ec
274
+ from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
275
+ signature = encode_dss_signature(int.from_bytes(b[41:73], 'big'), s)
276
+ # Never both: a schedule approval (no nonce, no gas fields) must not pass as one to execute.
277
+ p = purpose or 'approve'
278
+ message = safe_approval_message(
279
+ p, t['chainId'], '0x' + t['guard'].hex(), '0x' + t['safe'].hex(),
280
+ hashes['safeTxHash'] if p == 'approve' else hashes['governanceHash'], expires_at, parsed['displayDigest'])
281
+ try:
282
+ key.verify(signature, bytes.fromhex(message[2:]), ec.ECDSA(crypto_hashes.SHA256()))
283
+ except InvalidSignature:
284
+ return refuse('signature_invalid')
285
+ return {'valid': True, 'reason': None, 'purpose': p, **parsed, **hashes}
286
+
287
+
288
+ def _approvals(approvals: Iterable[str]) -> List[bytes]:
289
+ items = list(approvals)
290
+ if not 1 <= len(items) <= 255:
291
+ raise TouchQueConfigException('safe_guard: pass 1 to 255 approvals.')
292
+ out = [_hex(a if isinstance(a, str) and a.startswith('0x') else f'0x{a}', f'approvals[{i}]', 105) for i, a in enumerate(items)]
293
+ return sorted(out, key=lambda a: a[0])
294
+
295
+
296
+ def with_safe_approvals(owner_signatures: str, approvals: Iterable[str]) -> str:
297
+ """The owners' Safe signatures with the approvals appended (approvals by index | count | "TQG1"):
298
+ the ``signatures`` argument of ``execTransaction`` for an ordinary transaction."""
299
+ items = _approvals(approvals)
300
+ return '0x' + (_hex(owner_signatures, 'ownerSignatures') + b''.join(items) + bytes([len(items)]) + _MAGIC).hex()
301
+
302
+
303
+ def safe_schedule_call(safe: str, governance_hash: str, approvals: Iterable[str]) -> str:
304
+ """The call data of ``guard.schedule(safe, governanceHash, approvals)``, which starts a governance change's waiting time."""
305
+ joined = b''.join(_approvals(approvals))
306
+ return '0x' + (
307
+ _SCHEDULE_SELECTOR + _pad(_address(safe, 'safe')) + _hex(governance_hash, 'governanceHash', 32) + _word(96)
308
+ + _word(len(joined)) + joined + bytes(-len(joined) % 32)).hex()