offwork 0.4.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.
- offwork/__init__.py +167 -0
- offwork/__main__.py +770 -0
- offwork/_venv.py +174 -0
- offwork/core/__init__.py +15 -0
- offwork/core/errors.py +83 -0
- offwork/core/models.py +174 -0
- offwork/core/pairing.py +389 -0
- offwork/core/progress.py +91 -0
- offwork/core/signing.py +91 -0
- offwork/core/task.py +520 -0
- offwork/core/token.py +184 -0
- offwork/core/version.py +10 -0
- offwork/graph/__init__.py +5 -0
- offwork/graph/analyzer.py +637 -0
- offwork/graph/decorator.py +87 -0
- offwork/graph/graph.py +995 -0
- offwork/graph/store.py +500 -0
- offwork/graph/tracing.py +429 -0
- offwork/py.typed +0 -0
- offwork/typing.py +48 -0
- offwork/worker/__init__.py +18 -0
- offwork/worker/backends/__init__.py +3 -0
- offwork/worker/backends/base.py +149 -0
- offwork/worker/backends/http.py +237 -0
- offwork/worker/backends/local.py +452 -0
- offwork/worker/backends/rabbitmq.py +410 -0
- offwork/worker/backends/redis.py +175 -0
- offwork/worker/deps.py +365 -0
- offwork/worker/remote.py +793 -0
- offwork/worker/result.py +276 -0
- offwork/worker/sandbox/Dockerfile +24 -0
- offwork/worker/sandbox/__init__.py +18 -0
- offwork/worker/sandbox/_protocol.py +50 -0
- offwork/worker/sandbox/docker.py +438 -0
- offwork/worker/sandbox/guest_agent.py +622 -0
- offwork/worker/schedule.py +26 -0
- offwork/worker/worker.py +263 -0
- offwork-0.4.0.dist-info/METADATA +143 -0
- offwork-0.4.0.dist-info/RECORD +42 -0
- offwork-0.4.0.dist-info/WHEEL +4 -0
- offwork-0.4.0.dist-info/entry_points.txt +3 -0
- offwork-0.4.0.dist-info/licenses/LICENSE +661 -0
offwork/core/pairing.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""PIN-based pairing protocol for client-worker key exchange.
|
|
2
|
+
|
|
3
|
+
Pairing allows a client and worker to establish a shared secret by
|
|
4
|
+
entering the same short PIN on both sides. The protocol is inspired by
|
|
5
|
+
SPAKE2 / SAS-based verification and works as follows:
|
|
6
|
+
|
|
7
|
+
1. One side (the *initiator*) generates a random 6-digit PIN and shows it
|
|
8
|
+
to the user.
|
|
9
|
+
2. The user enters the same PIN on the other side (the *responder*).
|
|
10
|
+
3. Both sides independently derive an *intermediate secret* from the PIN
|
|
11
|
+
using a key-derivation step (HMAC-SHA256 with a fixed salt).
|
|
12
|
+
4. The initiator generates a random *challenge nonce* and sends it to the
|
|
13
|
+
responder over the backend channel.
|
|
14
|
+
5. The responder computes ``HMAC(intermediate_secret, nonce)`` and sends
|
|
15
|
+
the result back.
|
|
16
|
+
6. The initiator verifies the response. If it matches, both sides derive
|
|
17
|
+
the final shared secret from
|
|
18
|
+
``HMAC(intermediate_secret, nonce ‖ "confirmed")``.
|
|
19
|
+
|
|
20
|
+
The protocol prevents replay attacks (nonce is random per session) and
|
|
21
|
+
ensures that a passive eavesdropper who observes the nonce+response
|
|
22
|
+
cannot recover the PIN or the shared secret.
|
|
23
|
+
|
|
24
|
+
All primitives are stdlib-only.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import hmac
|
|
29
|
+
import json
|
|
30
|
+
import time
|
|
31
|
+
import asyncio
|
|
32
|
+
import hashlib
|
|
33
|
+
import logging
|
|
34
|
+
import secrets
|
|
35
|
+
from typing import Any
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from dataclasses import field, dataclass
|
|
38
|
+
|
|
39
|
+
from offwork.core.errors import PairingError
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# Fixed salt used to bind the PIN derivation to offwork
|
|
44
|
+
_PIN_SALT = b"offwork-pairing-v1"
|
|
45
|
+
|
|
46
|
+
# Where key material is persisted
|
|
47
|
+
_DEFAULT_KEY_DIR = Path.home() / ".offwork"
|
|
48
|
+
_CLIENT_KEY_FILE = "client.key"
|
|
49
|
+
_WORKER_KEY_FILE = "worker.key"
|
|
50
|
+
|
|
51
|
+
# PIN length (digits)
|
|
52
|
+
PIN_LENGTH = 6
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def generate_pin() -> str:
|
|
56
|
+
"""Generate a random numeric PIN of :data:`PIN_LENGTH` digits."""
|
|
57
|
+
# secrets.randbelow is cryptographically secure
|
|
58
|
+
num = secrets.randbelow(10 ** PIN_LENGTH)
|
|
59
|
+
return str(num).zfill(PIN_LENGTH)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _derive_intermediate(pin: str) -> bytes:
|
|
63
|
+
"""Derive a 32-byte intermediate key from a PIN string."""
|
|
64
|
+
return hmac.new(
|
|
65
|
+
_PIN_SALT, pin.encode("utf-8"), hashlib.sha256
|
|
66
|
+
).digest()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def generate_challenge() -> bytes:
|
|
70
|
+
"""Generate a 32-byte random challenge nonce."""
|
|
71
|
+
return os.urandom(32)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def compute_response(intermediate: bytes, challenge: bytes) -> bytes:
|
|
75
|
+
"""Compute the challenge-response: ``HMAC(intermediate, challenge)``."""
|
|
76
|
+
return hmac.new(intermediate, challenge, hashlib.sha256).digest()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def verify_response(
|
|
80
|
+
intermediate: bytes, challenge: bytes, response: bytes
|
|
81
|
+
) -> bool:
|
|
82
|
+
"""Verify a challenge-response in constant time."""
|
|
83
|
+
expected = compute_response(intermediate, challenge)
|
|
84
|
+
return hmac.compare_digest(expected, response)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def derive_shared_secret(intermediate: bytes, challenge: bytes) -> bytes:
|
|
88
|
+
"""Derive the final 32-byte shared secret after successful verification."""
|
|
89
|
+
material = challenge + b"confirmed"
|
|
90
|
+
return hmac.new(intermediate, material, hashlib.sha256).digest()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# -- High-level pairing state -----------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class PairingResult:
|
|
98
|
+
"""Outcome of a successful pairing exchange."""
|
|
99
|
+
|
|
100
|
+
shared_key: bytes
|
|
101
|
+
peer_role: str # "client" or "worker"
|
|
102
|
+
paired_at: float = field(default_factory=time.time)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# -- Pairing messages (JSON-serializable) -----------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _encode_bytes(b: bytes) -> str:
|
|
109
|
+
"""Encode bytes as hex for JSON transport."""
|
|
110
|
+
return b.hex()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _decode_bytes(s: str) -> bytes:
|
|
114
|
+
"""Decode hex string back to bytes."""
|
|
115
|
+
return bytes.fromhex(s)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def make_challenge_message(challenge: bytes) -> str:
|
|
119
|
+
"""Build the JSON pairing-challenge message."""
|
|
120
|
+
return json.dumps({
|
|
121
|
+
"type": "pairing_challenge",
|
|
122
|
+
"challenge": _encode_bytes(challenge),
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse_challenge_message(msg: str) -> bytes:
|
|
127
|
+
"""Extract the challenge nonce from a pairing-challenge message.
|
|
128
|
+
|
|
129
|
+
Raises
|
|
130
|
+
------
|
|
131
|
+
ValueError
|
|
132
|
+
If the message is not a valid pairing challenge.
|
|
133
|
+
"""
|
|
134
|
+
data = json.loads(msg)
|
|
135
|
+
if data.get("type") != "pairing_challenge":
|
|
136
|
+
raise ValueError(f"Expected pairing_challenge, got {data.get('type')!r}")
|
|
137
|
+
return _decode_bytes(data["challenge"])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def make_response_message(response: bytes) -> str:
|
|
141
|
+
"""Build the JSON pairing-response message."""
|
|
142
|
+
return json.dumps({
|
|
143
|
+
"type": "pairing_response",
|
|
144
|
+
"response": _encode_bytes(response),
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_response_message(msg: str) -> bytes:
|
|
149
|
+
"""Extract the response from a pairing-response message.
|
|
150
|
+
|
|
151
|
+
Raises
|
|
152
|
+
------
|
|
153
|
+
ValueError
|
|
154
|
+
If the message is not a valid pairing response.
|
|
155
|
+
"""
|
|
156
|
+
data = json.loads(msg)
|
|
157
|
+
if data.get("type") != "pairing_response":
|
|
158
|
+
raise ValueError(f"Expected pairing_response, got {data.get('type')!r}")
|
|
159
|
+
return _decode_bytes(data["response"])
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def make_confirm_message() -> str:
|
|
163
|
+
"""Build the JSON pairing-confirmed message."""
|
|
164
|
+
return json.dumps({"type": "pairing_confirmed"})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_confirm_message(msg: str) -> None:
|
|
168
|
+
"""Validate a pairing-confirmed message.
|
|
169
|
+
|
|
170
|
+
Raises
|
|
171
|
+
------
|
|
172
|
+
ValueError
|
|
173
|
+
If the message is not a valid pairing confirmation.
|
|
174
|
+
"""
|
|
175
|
+
data = json.loads(msg)
|
|
176
|
+
if data.get("type") != "pairing_confirmed":
|
|
177
|
+
raise ValueError(f"Expected pairing_confirmed, got {data.get('type')!r}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# -- Key persistence --------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _ensure_key_dir(key_dir: Path | None = None) -> Path:
|
|
184
|
+
"""Return the key directory, creating it if necessary."""
|
|
185
|
+
d = key_dir or _DEFAULT_KEY_DIR
|
|
186
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
return d
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def save_shared_key(
|
|
191
|
+
shared_key: bytes,
|
|
192
|
+
role: str,
|
|
193
|
+
key_dir: Path | None = None,
|
|
194
|
+
) -> Path:
|
|
195
|
+
"""Persist *shared_key* to disk.
|
|
196
|
+
|
|
197
|
+
Parameters
|
|
198
|
+
----------
|
|
199
|
+
shared_key
|
|
200
|
+
The 32-byte shared secret.
|
|
201
|
+
role
|
|
202
|
+
``"client"`` or ``"worker"`` — determines the filename.
|
|
203
|
+
key_dir
|
|
204
|
+
Override the default ``~/.offwork`` directory.
|
|
205
|
+
|
|
206
|
+
Returns
|
|
207
|
+
-------
|
|
208
|
+
Path
|
|
209
|
+
The file that was written.
|
|
210
|
+
"""
|
|
211
|
+
d = _ensure_key_dir(key_dir)
|
|
212
|
+
filename = _CLIENT_KEY_FILE if role == "client" else _WORKER_KEY_FILE
|
|
213
|
+
path = d / filename
|
|
214
|
+
path.write_bytes(shared_key)
|
|
215
|
+
# Restrict permissions: owner-only
|
|
216
|
+
path.chmod(0o600)
|
|
217
|
+
logger.info("Saved shared key to %s", path)
|
|
218
|
+
return path
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def load_shared_key(
|
|
222
|
+
role: str,
|
|
223
|
+
key_dir: Path | None = None,
|
|
224
|
+
) -> bytes | None:
|
|
225
|
+
"""Load a previously saved shared key, or return *None*.
|
|
226
|
+
|
|
227
|
+
Parameters
|
|
228
|
+
----------
|
|
229
|
+
role
|
|
230
|
+
``"client"`` or ``"worker"``.
|
|
231
|
+
key_dir
|
|
232
|
+
Override the default ``~/.offwork`` directory.
|
|
233
|
+
"""
|
|
234
|
+
d = _ensure_key_dir(key_dir)
|
|
235
|
+
filename = _CLIENT_KEY_FILE if role == "client" else _WORKER_KEY_FILE
|
|
236
|
+
path = d / filename
|
|
237
|
+
if not path.exists():
|
|
238
|
+
return None
|
|
239
|
+
key = path.read_bytes()
|
|
240
|
+
if len(key) != 32:
|
|
241
|
+
logger.warning("Invalid key file %s (expected 32 bytes, got %d)", path, len(key))
|
|
242
|
+
return None
|
|
243
|
+
return key
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def clear_shared_key(
|
|
247
|
+
role: str,
|
|
248
|
+
key_dir: Path | None = None,
|
|
249
|
+
) -> bool:
|
|
250
|
+
"""Delete a saved shared key. Returns ``True`` if a file was removed."""
|
|
251
|
+
d = _ensure_key_dir(key_dir)
|
|
252
|
+
filename = _CLIENT_KEY_FILE if role == "client" else _WORKER_KEY_FILE
|
|
253
|
+
path = d / filename
|
|
254
|
+
if path.exists():
|
|
255
|
+
path.unlink()
|
|
256
|
+
logger.info("Removed shared key %s", path)
|
|
257
|
+
return True
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# -- Backend channel helpers ------------------------------------------------
|
|
262
|
+
|
|
263
|
+
_PAIRING_CHANNEL = "offwork:pairing"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def initiate_pairing(
|
|
267
|
+
backend: Any,
|
|
268
|
+
pin: str,
|
|
269
|
+
timeout: float = 30.0,
|
|
270
|
+
) -> PairingResult:
|
|
271
|
+
"""Run the *initiator* side of the pairing protocol.
|
|
272
|
+
|
|
273
|
+
The initiator is typically the **worker**: it generates a challenge,
|
|
274
|
+
publishes it on the pairing channel, and waits for the client's
|
|
275
|
+
response.
|
|
276
|
+
|
|
277
|
+
Parameters
|
|
278
|
+
----------
|
|
279
|
+
backend
|
|
280
|
+
A offwork :class:`~offwork.worker.backends.base.Backend` instance
|
|
281
|
+
with ``send_progress`` / ``get_progress`` used as a simple KV
|
|
282
|
+
channel, or any object that exposes the pairing channel methods.
|
|
283
|
+
pin
|
|
284
|
+
The PIN entered by the user.
|
|
285
|
+
timeout
|
|
286
|
+
Seconds to wait for the peer to respond.
|
|
287
|
+
|
|
288
|
+
Raises
|
|
289
|
+
------
|
|
290
|
+
PairingError
|
|
291
|
+
On timeout or verification failure.
|
|
292
|
+
"""
|
|
293
|
+
intermediate = _derive_intermediate(pin)
|
|
294
|
+
challenge = generate_challenge()
|
|
295
|
+
|
|
296
|
+
# Publish challenge
|
|
297
|
+
challenge_msg = make_challenge_message(challenge)
|
|
298
|
+
await backend.send_progress(_PAIRING_CHANNEL, challenge_msg)
|
|
299
|
+
logger.debug("Pairing: sent challenge")
|
|
300
|
+
|
|
301
|
+
# Wait for response
|
|
302
|
+
deadline = time.monotonic() + timeout
|
|
303
|
+
while time.monotonic() < deadline:
|
|
304
|
+
raw = await backend.get_progress(_PAIRING_CHANNEL + ":response")
|
|
305
|
+
if raw is not None:
|
|
306
|
+
try:
|
|
307
|
+
response = parse_response_message(raw)
|
|
308
|
+
except (ValueError, json.JSONDecodeError):
|
|
309
|
+
await asyncio.sleep(0.5)
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
if not verify_response(intermediate, challenge, response):
|
|
313
|
+
raise PairingError("PIN mismatch — pairing failed")
|
|
314
|
+
|
|
315
|
+
# Derive shared secret and confirm
|
|
316
|
+
shared = derive_shared_secret(intermediate, challenge)
|
|
317
|
+
confirm_msg = make_confirm_message()
|
|
318
|
+
await backend.send_progress(_PAIRING_CHANNEL + ":confirm", confirm_msg)
|
|
319
|
+
logger.info("Pairing successful (initiator)")
|
|
320
|
+
return PairingResult(shared_key=shared, peer_role="client")
|
|
321
|
+
|
|
322
|
+
await asyncio.sleep(0.5)
|
|
323
|
+
|
|
324
|
+
raise PairingError(f"Pairing timed out after {timeout}s — no response from peer")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
async def respond_to_pairing(
|
|
328
|
+
backend: Any,
|
|
329
|
+
pin: str,
|
|
330
|
+
timeout: float = 30.0,
|
|
331
|
+
) -> PairingResult:
|
|
332
|
+
"""Run the *responder* side of the pairing protocol.
|
|
333
|
+
|
|
334
|
+
The responder is typically the **client**: it waits for the worker's
|
|
335
|
+
challenge, computes a response, and waits for confirmation.
|
|
336
|
+
|
|
337
|
+
Parameters
|
|
338
|
+
----------
|
|
339
|
+
backend
|
|
340
|
+
A offwork backend instance.
|
|
341
|
+
pin
|
|
342
|
+
The PIN entered by the user.
|
|
343
|
+
timeout
|
|
344
|
+
Seconds to wait for the challenge and confirmation.
|
|
345
|
+
|
|
346
|
+
Raises
|
|
347
|
+
------
|
|
348
|
+
PairingError
|
|
349
|
+
On timeout or if the initiator rejects the response.
|
|
350
|
+
"""
|
|
351
|
+
intermediate = _derive_intermediate(pin)
|
|
352
|
+
|
|
353
|
+
# Wait for challenge
|
|
354
|
+
deadline = time.monotonic() + timeout
|
|
355
|
+
challenge: bytes | None = None
|
|
356
|
+
while time.monotonic() < deadline:
|
|
357
|
+
raw = await backend.get_progress(_PAIRING_CHANNEL)
|
|
358
|
+
if raw is not None:
|
|
359
|
+
try:
|
|
360
|
+
challenge = parse_challenge_message(raw)
|
|
361
|
+
break
|
|
362
|
+
except (ValueError, json.JSONDecodeError):
|
|
363
|
+
pass
|
|
364
|
+
await asyncio.sleep(0.5)
|
|
365
|
+
|
|
366
|
+
if challenge is None:
|
|
367
|
+
raise PairingError(f"Pairing timed out after {timeout}s — no challenge from peer")
|
|
368
|
+
|
|
369
|
+
# Compute and send response
|
|
370
|
+
response = compute_response(intermediate, challenge)
|
|
371
|
+
response_msg = make_response_message(response)
|
|
372
|
+
await backend.send_progress(_PAIRING_CHANNEL + ":response", response_msg)
|
|
373
|
+
logger.debug("Pairing: sent response")
|
|
374
|
+
|
|
375
|
+
# Wait for confirmation
|
|
376
|
+
while time.monotonic() < deadline:
|
|
377
|
+
raw = await backend.get_progress(_PAIRING_CHANNEL + ":confirm")
|
|
378
|
+
if raw is not None:
|
|
379
|
+
try:
|
|
380
|
+
parse_confirm_message(raw)
|
|
381
|
+
shared = derive_shared_secret(intermediate, challenge)
|
|
382
|
+
logger.info("Pairing successful (responder)")
|
|
383
|
+
return PairingResult(shared_key=shared, peer_role="worker")
|
|
384
|
+
except (ValueError, json.JSONDecodeError):
|
|
385
|
+
pass
|
|
386
|
+
await asyncio.sleep(0.5)
|
|
387
|
+
|
|
388
|
+
raise PairingError("Pairing failed — initiator did not confirm")
|
|
389
|
+
|
offwork/core/progress.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Progress reporting for remote task execution."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import contextvars
|
|
5
|
+
from typing import Any, Self, overload
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ProgressInfo:
|
|
12
|
+
"""Progress information reported by a running task.
|
|
13
|
+
|
|
14
|
+
Returned by :meth:`Result.progress`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
current: float = 0
|
|
18
|
+
total: float | None = None
|
|
19
|
+
message: str | None = None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def percent(self) -> float | None:
|
|
23
|
+
"""Return completion percentage, or ``None`` if *total* is unknown."""
|
|
24
|
+
if self.total is not None and self.total > 0:
|
|
25
|
+
return self.current / self.total * 100
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
def to_json(self) -> str:
|
|
29
|
+
"""Serialize to a JSON string."""
|
|
30
|
+
d: dict[str, Any] = {"current": self.current}
|
|
31
|
+
if self.total is not None:
|
|
32
|
+
d["total"] = self.total
|
|
33
|
+
if self.message is not None:
|
|
34
|
+
d["message"] = self.message
|
|
35
|
+
return json.dumps(d)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_json(cls, raw: str | bytes) -> Self:
|
|
39
|
+
"""Deserialize from a JSON string or bytes."""
|
|
40
|
+
data = json.loads(raw)
|
|
41
|
+
return cls(
|
|
42
|
+
current=data["current"],
|
|
43
|
+
total=data.get("total"),
|
|
44
|
+
message=data.get("message"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_progress_callback: contextvars.ContextVar[
|
|
49
|
+
Callable[[float, float | None, str | None], None] | None
|
|
50
|
+
] = contextvars.ContextVar("offwork_progress", default=None)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@overload
|
|
54
|
+
def progress(percent: float, /, *, message: str | None = None) -> None: ...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@overload
|
|
58
|
+
def progress(current: int, total: int, /, *, message: str | None = None) -> None: ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def progress(
|
|
62
|
+
_value: float,
|
|
63
|
+
_total: int | None = None,
|
|
64
|
+
/,
|
|
65
|
+
*,
|
|
66
|
+
message: str | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Report task progress from within a running function.
|
|
69
|
+
|
|
70
|
+
Call this inside a ``@trace``-decorated function to report progress
|
|
71
|
+
to the client. When called outside a worker, this is a silent no-op.
|
|
72
|
+
|
|
73
|
+
Accepts either a percentage or a current/total pair::
|
|
74
|
+
|
|
75
|
+
progress(75.0) # 75 % complete
|
|
76
|
+
progress(75.0, message="loading model") # 75 % with message
|
|
77
|
+
progress(3, 10) # 3 of 10 (30 %)
|
|
78
|
+
progress(3, 10, message="step 3") # 3 of 10 with message
|
|
79
|
+
"""
|
|
80
|
+
cb = _progress_callback.get(None)
|
|
81
|
+
if cb is None:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
if isinstance(_total, (int, float)):
|
|
85
|
+
if _total <= 0:
|
|
86
|
+
raise ValueError("Progress total must be a positive number.")
|
|
87
|
+
# current / total form
|
|
88
|
+
cb(_value, _total, message)
|
|
89
|
+
else:
|
|
90
|
+
# percent form — normalise to current/100
|
|
91
|
+
cb(_value, 100, message)
|
offwork/core/signing.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Cryptographic task signing and verification.
|
|
2
|
+
|
|
3
|
+
After a client and worker are paired (see :mod:`offwork.core.pairing`),
|
|
4
|
+
they share a secret key. The client uses it to produce an HMAC-SHA256
|
|
5
|
+
signature over the serialized task payload; the worker verifies that
|
|
6
|
+
signature before executing the task.
|
|
7
|
+
|
|
8
|
+
All primitives are stdlib-only (``hashlib``, ``hmac``, ``json``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import hashlib
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from offwork.core.errors import SignatureError
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def compute_signature(payload: str, key: bytes) -> str:
|
|
23
|
+
"""Return a hex-encoded HMAC-SHA256 signature of *payload*.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
payload
|
|
28
|
+
The string to sign (typically the JSON body of a task).
|
|
29
|
+
key
|
|
30
|
+
Shared secret derived from the pairing process.
|
|
31
|
+
"""
|
|
32
|
+
return hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def verify_signature(payload: str, signature: str, key: bytes) -> bool:
|
|
36
|
+
"""Verify an HMAC-SHA256 *signature* over *payload*.
|
|
37
|
+
|
|
38
|
+
Uses :func:`hmac.compare_digest` for constant-time comparison.
|
|
39
|
+
"""
|
|
40
|
+
expected = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
41
|
+
return hmac.compare_digest(expected, signature)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# -- Signed JSON helpers ----------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sign_json(data: dict[str, Any], key: bytes) -> str:
|
|
48
|
+
"""Serialize *data* to JSON, attach an HMAC-SHA256 signature, and return the envelope.
|
|
49
|
+
|
|
50
|
+
The returned JSON has the shape::
|
|
51
|
+
|
|
52
|
+
{"payload": "<inner-json>", "signature": "<hex>"}
|
|
53
|
+
"""
|
|
54
|
+
payload = json.dumps(data, separators=(",", ":"), sort_keys=True)
|
|
55
|
+
sig = compute_signature(payload, key)
|
|
56
|
+
return json.dumps({"payload": payload, "signature": sig})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def verify_and_load_json(envelope_json: str, key: bytes) -> dict[str, Any]:
|
|
60
|
+
"""Parse *envelope_json*, verify the signature, and return the inner data.
|
|
61
|
+
|
|
62
|
+
Raises
|
|
63
|
+
------
|
|
64
|
+
SignatureError
|
|
65
|
+
If the signature is missing or invalid.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
envelope = json.loads(envelope_json)
|
|
69
|
+
except (json.JSONDecodeError, TypeError) as exc:
|
|
70
|
+
raise SignatureError(f"Invalid signed envelope: {exc}") from exc
|
|
71
|
+
|
|
72
|
+
payload = envelope.get("payload")
|
|
73
|
+
signature = envelope.get("signature")
|
|
74
|
+
if payload is None or signature is None:
|
|
75
|
+
raise SignatureError("Envelope missing 'payload' or 'signature' field")
|
|
76
|
+
|
|
77
|
+
if not verify_signature(payload, signature, key):
|
|
78
|
+
raise SignatureError("HMAC signature verification failed")
|
|
79
|
+
|
|
80
|
+
return json.loads(payload) # type: ignore[no-any-return]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# -- Key derivation ---------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def derive_key(shared_secret: bytes, context: str = "offwork-task-signing") -> bytes:
|
|
87
|
+
"""Derive a 32-byte HMAC signing key from a shared secret.
|
|
88
|
+
|
|
89
|
+
Uses HKDF-like expansion via ``HMAC-SHA256(secret, context)``.
|
|
90
|
+
"""
|
|
91
|
+
return hmac.new(shared_secret, context.encode("utf-8"), hashlib.sha256).digest()
|