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