Skip to content

Commit c30fa82

Browse files
committed
fix: protect dAuth secret requests from replay
1 parent 604a8aa commit c30fa82

4 files changed

Lines changed: 141 additions & 7 deletions

File tree

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,3 +695,12 @@ Entry format:
695695
- Details: `ThHfModelBase` keeps Transformers/PT as the default GPU and fallback path, but CPU-only `HF_RUNTIME=auto` now loads `artifact_manifest.json`, selects a declared ONNX Runtime artifact, downloads only safe allow-patterns, loads schema and contract decoder from HF artifacts, and exposes the decoded artifact contract through the existing text-classifier flow. Business API response shaping now passes through generic model/runtime metadata emitted by serving.
696696
- Verification: `python3 -m unittest extensions.serving.test_th_hf_model_base extensions.serving.test_th_text_classifier extensions.serving.test_th_privacy_filter extensions.business.edge_inference_api.test_text_classifier_inference_api extensions.business.edge_inference_api.test_privacy_filter_inference_api`; `python3 -m py_compile extensions/serving/default_inference/nlp/th_hf_model_base.py extensions/business/edge_inference_api/text_classifier_inference_api.py`; required serving gate `python3 -m unittest extensions.serving.model_testing.test_llm_servings` currently fails at import with `ImportError: cannot import name 'Logger' from 'naeural_core'`.
697697
- Links: `extensions/serving/default_inference/nlp/th_hf_model_base.py`, `extensions/business/edge_inference_api/text_classifier_inference_api.py`, `extensions/serving/test_th_hf_model_base.py`
698+
699+
- ID: `ML-20260723-001`
700+
- Timestamp: `2026-07-23T13:45:20Z`
701+
- Type: `change`
702+
- Summary: dAuth job-secret requests now require signed 120-second timestamp nonces, and GET responses encrypt secret bundles to the authorized runner.
703+
- Criticality: Security protocol change preventing indefinite signed-request/response replay and removing plaintext job secrets from HTTP responses.
704+
- Details: `/add_secrets` and `/get_secrets` validate signed hex-millisecond timestamp nonces and echo them in successful signed responses. `/get_secrets` encrypts the serialized bundle to the signed requester address; clients must verify the response signer and echoed nonce before decrypting.
705+
- Verification: `python -m unittest discover -s extensions/business/dauth -p 'test_*.py'`; cross-repo SDK dAuth client tests.
706+
- Links: `extensions/business/dauth/dauth_mixin.py`, `extensions/business/dauth/dauth_manager.py`

extensions/business/dauth/dauth_manager.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -347,11 +347,16 @@ def get_auth_data(self, body: dict):
347347
# /add_secrets
348348
def add_secrets(self, body: dict):
349349
"""
350-
Store the full dAuth secret bundle for a job. Only protocol oracles can write.
350+
Store a full job secret bundle from a protocol oracle.
351+
352+
The signed request must include a hex-millisecond timestamp nonce no older
353+
than 120 seconds.
351354
"""
355+
request_nonce = body.get("nonce") if isinstance(body, dict) else None
352356
if not self._dauth_server_enabled:
353357
response = self.__get_response({
354-
'error': 'dAuth server is not registered as a dAuth oracle'
358+
'error': 'dAuth server is not registered as a dAuth oracle',
359+
'nonce': request_nonce,
355360
})
356361
return response
357362

@@ -364,6 +369,7 @@ def add_secrets(self, body: dict):
364369
}
365370

366371
response = self.__get_response({
372+
'nonce': request_nonce,
367373
**data
368374
})
369375
return response
@@ -372,12 +378,16 @@ def add_secrets(self, body: dict):
372378
# /get_secrets
373379
def get_secrets(self, body: dict):
374380
"""
375-
Return the full dAuth secret bundle for a job. Only nodes currently running the
376-
job in the R1FS-stored pipeline can read.
381+
Return an encrypted job secret bundle to a current R1FS job runner.
382+
383+
The signed request must include a hex-millisecond timestamp nonce no older
384+
than 120 seconds. The signed response echoes that nonce.
377385
"""
386+
request_nonce = body.get("nonce") if isinstance(body, dict) else None
378387
if not self._dauth_server_enabled:
379388
response = self.__get_response({
380-
'error': 'dAuth server is not registered as a dAuth oracle'
389+
'error': 'dAuth server is not registered as a dAuth oracle',
390+
'nonce': request_nonce,
381391
})
382392
return response
383393

@@ -390,6 +400,7 @@ def get_secrets(self, body: dict):
390400
}
391401

392402
response = self.__get_response({
403+
'nonce': request_nonce,
393404
**data
394405
})
395406
return response

extensions/business/dauth/dauth_mixin.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
DAUTH_JOB_SECRETS_CSTORE_HKEY = "DAUTH_JOB_SECRETS"
1919
DEEPLOY_JOBS_CSTORE_HKEY = "DEEPLOY_DEPLOYED_JOBS"
20+
DAUTH_SECRET_REQUEST_MAX_AGE_SECONDS = 120
2021

2122

2223
def version_to_int(version):
@@ -192,6 +193,23 @@ def _is_protocol_oracle_eth(self, node_address_eth):
192193
raise ValueError("No oracles found - this is a critical issue!")
193194
return node_address_eth.lower() in [addr.lower() for addr in eth_oracles]
194195

196+
def _validate_dauth_secret_request_nonce(self, body):
197+
"""Validate the signed hex-millisecond timestamp nonce."""
198+
nonce = body.get(self.const.BASE_CT.dAuth.DAUTH_NONCE)
199+
if not isinstance(nonce, str) or not nonce:
200+
raise ValueError("dAuth request nonce is required.")
201+
try:
202+
request_time = int(nonce, 16) / 1000
203+
except (TypeError, ValueError) as exc:
204+
raise ValueError("dAuth request nonce is invalid.") from exc
205+
206+
request_age = self.time() - request_time
207+
if request_age < 0:
208+
raise ValueError("dAuth request nonce is from the future.")
209+
if request_age > DAUTH_SECRET_REQUEST_MAX_AGE_SECONDS:
210+
raise ValueError("dAuth request nonce is expired.")
211+
return nonce
212+
195213
def _normalize_dauth_job_id(self, job_id):
196214
if job_id in [None, ""]:
197215
raise ValueError("Job ID is required.")
@@ -262,6 +280,7 @@ def _is_node_running_dauth_job(self, job_id, node_address):
262280

263281
def process_dauth_add_secrets_request(self, body):
264282
requester, requester_eth = self._verify_signed_dauth_body(body)
283+
request_nonce = self._validate_dauth_secret_request_nonce(body)
265284
if not self._is_protocol_oracle_eth(requester_eth):
266285
raise ValueError(f"Sender {requester_eth} is not an oracle.")
267286

@@ -272,10 +291,12 @@ def process_dauth_add_secrets_request(self, body):
272291
return {
273292
"status": "success",
274293
"job_id": job_id,
294+
self.const.BASE_CT.dAuth.DAUTH_NONCE: request_nonce,
275295
}
276296

277297
def process_dauth_get_secret_request(self, body):
278298
requester, _ = self._verify_signed_dauth_body(body)
299+
request_nonce = self._validate_dauth_secret_request_nonce(body)
279300
job_id = self._normalize_dauth_job_id(body.get("job_id"))
280301

281302
if not self._is_node_running_dauth_job(job_id, requester):
@@ -284,11 +305,18 @@ def process_dauth_get_secret_request(self, body):
284305
secret_bundle = self._load_dauth_job_secret_bundle(job_id)
285306
if not isinstance(secret_bundle, dict):
286307
raise ValueError(f"No dAuth secret bundle found for job {job_id}.")
308+
encrypted_secret_bundle = self.bc.encrypt_str(
309+
str_data=self.json_dumps(secret_bundle),
310+
str_recipient=requester,
311+
)
312+
if not isinstance(encrypted_secret_bundle, str) or not encrypted_secret_bundle:
313+
raise ValueError(f"Failed to encrypt dAuth secrets for job {job_id}.")
287314

288315
return {
289316
"status": "success",
290317
"job_id": job_id,
291-
"secret_bundle": secret_bundle,
318+
self.const.BASE_CT.dAuth.DAUTH_NONCE: request_nonce,
319+
"encrypted_secret_bundle": encrypted_secret_bundle,
292320
}
293321

294322
def chainstore_store_dauth_request(

extensions/business/dauth/test_dauth_registry_gating.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collections import deque
2+
import json
23
import queue
34
import threading
45
import unittest
@@ -13,6 +14,8 @@
1314

1415

1516
ROOT = Path(__file__).resolve().parents[3]
17+
REQUEST_TIME = 1_700_000_000
18+
REQUEST_NONCE = hex(REQUEST_TIME * 1000)
1619

1720

1821
class _FakeProcess:
@@ -139,6 +142,7 @@ def _load_dauth_manager_class():
139142

140143

141144
class _FakeDauthConst:
145+
DAUTH_NONCE = "nonce"
142146
DAUTH_ENV_KEYS_PREFIX = "EE_"
143147
DAUTH_WHITELIST = "DAUTH_WHITELIST"
144148

@@ -170,6 +174,7 @@ def __init__(self, *, dauth_oracle=True, protocol_oracles=None, valid_signature=
170174
self.dauth_oracle = dauth_oracle
171175
self.protocol_oracles = protocol_oracles or ["node-oracle"]
172176
self.valid_signature = valid_signature
177+
self.encrypt_calls = []
173178
self.node_eth = {
174179
"node-oracle": "0xORACLE",
175180
"node-runner": "0xRUNNER",
@@ -211,6 +216,10 @@ def maybe_add_prefix(self, node_address):
211216
return node_address
212217
return "0xai_" + node_address
213218

219+
def encrypt_str(self, str_data, str_recipient):
220+
self.encrypt_calls.append((str_data, str_recipient))
221+
return "encrypted-secret-bundle"
222+
214223

215224
class _FakeR1FS:
216225

@@ -234,6 +243,8 @@ def _make_dauth_harness(*, dauth_oracle=True, protocol_oracles=None, valid_signa
234243
valid_signature=valid_signature,
235244
)
236245
plugin.deepcopy = deepcopy
246+
plugin.json_dumps = json.dumps
247+
plugin.time = lambda: REQUEST_TIME
237248
plugin._chainstore = {}
238249
plugin._r1fs_data = {}
239250
plugin.r1fs = _FakeR1FS(plugin._r1fs_data)
@@ -334,6 +345,30 @@ def test_dauth_token_fails_closed_when_dauth_registry_check_fails(self):
334345

335346
class DauthJobSecretEndpointTests(unittest.TestCase):
336347

348+
def test_secret_request_nonce_accepts_only_last_120_seconds(self):
349+
plugin = _make_dauth_harness()
350+
351+
self.assertEqual(
352+
plugin._validate_dauth_secret_request_nonce({"nonce": REQUEST_NONCE}),
353+
REQUEST_NONCE,
354+
)
355+
boundary_nonce = hex(int((REQUEST_TIME - 120) * 1000))
356+
self.assertEqual(
357+
plugin._validate_dauth_secret_request_nonce({"nonce": boundary_nonce}),
358+
boundary_nonce,
359+
)
360+
361+
invalid_nonces = (
362+
({}, "required"),
363+
({"nonce": "not-hex"}, "invalid"),
364+
({"nonce": hex(int((REQUEST_TIME + 1) * 1000))}, "future"),
365+
({"nonce": hex(int((REQUEST_TIME - 121) * 1000))}, "expired"),
366+
)
367+
for body, message in invalid_nonces:
368+
with self.subTest(body=body):
369+
with self.assertRaisesRegex(ValueError, message):
370+
plugin._validate_dauth_secret_request_nonce(body)
371+
337372
def test_add_secrets_allows_protocol_oracle_and_overwrites_bundle(self):
338373
plugin = _make_dauth_harness(protocol_oracles=["node-oracle"])
339374
plugin._chainstore[(DAUTH_JOB_SECRETS_CSTORE_HKEY, "7")] = {
@@ -343,6 +378,7 @@ def test_add_secrets_allows_protocol_oracle_and_overwrites_bundle(self):
343378
body = {
344379
"EE_SENDER": "node-oracle",
345380
"EE_ETH_SENDER": "0xORACLE",
381+
"nonce": REQUEST_NONCE,
346382
"job_id": 7,
347383
"job_secrets": {
348384
"plugins": {
@@ -361,6 +397,7 @@ def test_add_secrets_allows_protocol_oracle_and_overwrites_bundle(self):
361397

362398
self.assertEqual(response["status"], "success")
363399
self.assertEqual(response["job_id"], "7")
400+
self.assertEqual(response["nonce"], REQUEST_NONCE)
364401
self.assertEqual(
365402
plugin._chainstore[(DAUTH_JOB_SECRETS_CSTORE_HKEY, "7")],
366403
{
@@ -374,6 +411,7 @@ def test_add_secrets_rejects_non_oracle_writer(self):
374411
body = {
375412
"EE_SENDER": "node-runner",
376413
"EE_ETH_SENDER": "0xRUNNER",
414+
"nonce": REQUEST_NONCE,
377415
"job_id": "7",
378416
"job_secrets": {"plugins": {}},
379417
}
@@ -383,11 +421,27 @@ def test_add_secrets_rejects_non_oracle_writer(self):
383421

384422
self.assertNotIn((DAUTH_JOB_SECRETS_CSTORE_HKEY, "7"), plugin._chainstore)
385423

424+
def test_add_secrets_rejects_expired_nonce_before_write(self):
425+
plugin = _make_dauth_harness()
426+
body = {
427+
"EE_SENDER": "node-oracle",
428+
"EE_ETH_SENDER": "0xORACLE",
429+
"nonce": hex(int((REQUEST_TIME - 121) * 1000)),
430+
"job_id": "7",
431+
"job_secrets": {"plugins": {}},
432+
}
433+
434+
with self.assertRaisesRegex(ValueError, "nonce is expired"):
435+
plugin.process_dauth_add_secrets_request(body)
436+
437+
self.assertNotIn((DAUTH_JOB_SECRETS_CSTORE_HKEY, "7"), plugin._chainstore)
438+
386439
def test_add_secrets_rejects_invalid_signature(self):
387440
plugin = _make_dauth_harness(valid_signature=False)
388441
body = {
389442
"EE_SENDER": "node-oracle",
390443
"EE_ETH_SENDER": "0xORACLE",
444+
"nonce": REQUEST_NONCE,
391445
"job_id": "7",
392446
"job_secrets": {"plugins": {}},
393447
}
@@ -402,6 +456,7 @@ def test_add_secrets_rejects_legacy_plugin_secrets_shape(self):
402456
body = {
403457
"EE_SENDER": "node-oracle",
404458
"EE_ETH_SENDER": "0xORACLE",
459+
"nonce": REQUEST_NONCE,
405460
"job_id": "7",
406461
"plugin_secrets": {"plugins": {}},
407462
}
@@ -437,14 +492,24 @@ def test_get_secrets_returns_bundle_for_node_running_job_from_r1fs_pipeline(self
437492
body = {
438493
"EE_SENDER": "node-runner",
439494
"EE_ETH_SENDER": "0xRUNNER",
495+
"nonce": REQUEST_NONCE,
440496
"job_id": "7",
441497
}
442498

443499
response = plugin.process_dauth_get_secret_request(body)
444500

445501
self.assertEqual(response["status"], "success")
446502
self.assertEqual(response["job_id"], "7")
447-
self.assertEqual(response["secret_bundle"], bundle)
503+
self.assertEqual(response["nonce"], REQUEST_NONCE)
504+
self.assertEqual(
505+
response["encrypted_secret_bundle"],
506+
"encrypted-secret-bundle",
507+
)
508+
self.assertNotIn("secret_bundle", response)
509+
self.assertEqual(
510+
plugin.bc.encrypt_calls,
511+
[(json.dumps(bundle), "node-runner")],
512+
)
448513

449514
def test_get_secrets_rejects_node_not_running_job(self):
450515
plugin = _make_dauth_harness()
@@ -461,6 +526,7 @@ def test_get_secrets_rejects_node_not_running_job(self):
461526
body = {
462527
"EE_SENDER": "node-other",
463528
"EE_ETH_SENDER": "0xOTHER",
529+
"nonce": REQUEST_NONCE,
464530
"job_id": "7",
465531
}
466532

@@ -500,8 +566,28 @@ def is_dauth_oracle(self):
500566
plugin._init_request_tracking = lambda: None
501567
plugin.bc.address = "node-address"
502568
plugin.bc.eth_address = "0xNODE"
569+
plugin._DauthManagerPlugin__get_response = lambda data: data
503570
return plugin
504571

572+
def test_secret_endpoint_errors_echo_request_nonce(self):
573+
plugin = self._make_manager(dauth_oracle=True)
574+
plugin._dauth_server_enabled = True
575+
plugin.process_dauth_add_secrets_request = lambda body: (_ for _ in ()).throw(
576+
ValueError("add failed")
577+
)
578+
plugin.process_dauth_get_secret_request = lambda body: (_ for _ in ()).throw(
579+
ValueError("get failed")
580+
)
581+
body = {"nonce": REQUEST_NONCE}
582+
583+
add_response = plugin.add_secrets(body)
584+
get_response = plugin.get_secrets(body)
585+
586+
self.assertEqual(add_response["nonce"], REQUEST_NONCE)
587+
self.assertEqual(add_response["error"], "add failed")
588+
self.assertEqual(get_response["nonce"], REQUEST_NONCE)
589+
self.assertEqual(get_response["error"], "get failed")
590+
505591
def test_startup_lookup_is_cached_across_repeated_lifecycle_predicates(self):
506592
plugin = self._make_manager(dauth_oracle=True)
507593

0 commit comments

Comments
 (0)