@ethernity-cloud/sdk-js 1.6.2 → 1.7.1
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.
- package/README.md +86 -48
- package/cas/config.js +61 -3
- package/info.js +14 -5
- package/init.js +24 -6
- package/network.js +51 -0
- package/nodenithy/build/securelock/Dockerfile.tmpl +34 -32
- package/nodenithy/build/securelock/scripts/binary-fs-build.sh +7 -6
- package/nodenithy/build.mjs +80 -68
- package/nodenithy/run/docker-compose-final.yml.tmpl +36 -31
- package/nodenithy/run/docker-compose.yml.tmpl +34 -45
- package/nodenithy/run/image_registry.js +17 -4
- package/nodenithy/run.js +42 -28
- package/nodenithy/src/ec_helloworld_example.js +46 -42
- package/nodenithy/src/preStart.mjs +41 -51
- package/package.json +6 -3
- package/publish.js +4 -2
- package/pynithy/build/securelock/Dockerfile.base.tpl +13 -0
- package/pynithy/build/securelock/Dockerfile.tpl +131 -0
- package/pynithy/build/securelock/scripts/binary-fs-build.sh +25 -34
- package/pynithy/build/securelock/scripts/pyfreeze.sh +32 -0
- package/pynithy/build/securelock/src/{pox.abi → abi/etnyImplementationV2.abi} +1489 -1489
- package/pynithy/build/securelock/src/abi/image_registry.abi +1 -0
- package/pynithy/build/securelock/src/ecld_state.py +906 -0
- package/pynithy/build/securelock/src/esr.abi +1 -0
- package/pynithy/build/securelock/src/esr_local.py +267 -0
- package/pynithy/build/securelock/src/esr_wallet.py +150 -0
- package/pynithy/build/securelock/src/etny_crypto.py +208 -167
- package/pynithy/build/securelock/src/etny_exec.py +385 -79
- package/pynithy/build/securelock/src/etny_exec_flask.py +535 -0
- package/pynithy/build/securelock/src/etny_exec_serv.py +353 -0
- package/pynithy/build/securelock/src/etny_session_exec.py +245 -0
- package/pynithy/build/securelock/src/get_sgx_report.so +0 -0
- package/pynithy/build/securelock/src/image_registry.abi +1 -1
- package/pynithy/build/securelock/src/key_generation.py +17 -17
- package/pynithy/build/securelock/src/local_api.py +189 -0
- package/pynithy/build/securelock/src/models.py +200 -176
- package/pynithy/build/securelock/src/securelock.py.tmpl +1037 -731
- package/pynithy/build/securelock/src/swift_stream_service.py +206 -186
- package/pynithy/build.mjs +139 -89
- package/pynithy/run/docker-compose-final.yml.tmpl +58 -51
- package/pynithy/run/docker-compose.yml.tmpl +62 -48
- package/pynithy/run/etny-securelock-test.yaml.tpl +4 -1
- package/pynithy/run/image_registry.js +17 -4
- package/pynithy/run.js +44 -28
- package/pynithy/src/ec_helloworld_example.js +46 -42
- package/pynithy/src/preStart.mjs +41 -51
- package/pynithy/src/serverless/Dockerfile.serverless +8 -0
- package/pynithy/src/serverless/requirements.txt +6 -0
- package/run.js +37 -34
- package/nodenithy/build/las/Dockerfile +0 -5
- package/nodenithy/src/preStart.js +0 -45
- package/pynithy/build/docker-compose.yml +0 -70
- package/pynithy/build/las/Dockerfile +0 -11
- package/pynithy/build/las/src/etny_entrypoint.sh +0 -6
- package/pynithy/build/las/src/las_entrypoint.sh +0 -26
- package/pynithy/build/scripts/binary-fs-build.sh +0 -28
- package/pynithy/build/securelock/Dockerfile.tmpl +0 -95
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# etny_exec.py
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import ast
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import secrets
|
|
10
|
+
import socket
|
|
11
|
+
import logging
|
|
12
|
+
from flask import Flask, request, jsonify
|
|
13
|
+
from functools import wraps
|
|
14
|
+
from flask_limiter import Limiter
|
|
15
|
+
from flask_limiter.util import get_remote_address
|
|
16
|
+
from pyngrok import ngrok
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
|
18
|
+
import importlib
|
|
19
|
+
|
|
20
|
+
# ===========================
|
|
21
|
+
# Backend Functions Loading
|
|
22
|
+
# ===========================
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import serverless.backend as backend
|
|
26
|
+
except ImportError:
|
|
27
|
+
backend = None
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
sdkFunctions = {}
|
|
31
|
+
if backend is not None:
|
|
32
|
+
for func in backend.__dict__.keys():
|
|
33
|
+
if func not in backend.__builtins__.keys() and func not in [
|
|
34
|
+
"__file__",
|
|
35
|
+
"__cached__",
|
|
36
|
+
"__builtins__",
|
|
37
|
+
]:
|
|
38
|
+
sdkFunctions.update({func: backend.__dict__[func]})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ===========================
|
|
42
|
+
# Task Status Definitions
|
|
43
|
+
# ===========================
|
|
44
|
+
|
|
45
|
+
class TaskStatus:
|
|
46
|
+
SUCCESS = 0
|
|
47
|
+
SYSTEM_ERROR = 1
|
|
48
|
+
KEY_ERROR = 2
|
|
49
|
+
SYNTAX_WARNING = 3
|
|
50
|
+
BASE_EXCEPTION = 4
|
|
51
|
+
PAYLOAD_NOT_DEFINED = 5
|
|
52
|
+
PAYLOAD_CHECKSUM_ERROR = 6
|
|
53
|
+
INPUT_CHECKSUM_ERROR = 7
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ===========================
|
|
57
|
+
# Flask and ngrok Setup
|
|
58
|
+
# ===========================
|
|
59
|
+
|
|
60
|
+
class EthernityEnclaveServer:
|
|
61
|
+
def __init__(self, local_port=5000):
|
|
62
|
+
self.api_key = self.generate_api_key()
|
|
63
|
+
self.local_port = local_port
|
|
64
|
+
self.ngrok_url = None
|
|
65
|
+
self.app = Flask(__name__)
|
|
66
|
+
self.setup_logging()
|
|
67
|
+
self.setup_security()
|
|
68
|
+
self.setup_execution_environment()
|
|
69
|
+
self.setup_routes()
|
|
70
|
+
self.server_thread = threading.Thread(target=self.run_flask, daemon=True)
|
|
71
|
+
self.ngrok_thread = threading.Thread(target=self.start_ngrok, daemon=True)
|
|
72
|
+
|
|
73
|
+
def generate_api_key(self):
|
|
74
|
+
"""Generates a secure random API key."""
|
|
75
|
+
return secrets.token_hex(16) # Generates a 32-character hexadecimal string
|
|
76
|
+
|
|
77
|
+
def setup_logging(self):
|
|
78
|
+
"""Configures logging with rotation."""
|
|
79
|
+
from logging.handlers import RotatingFileHandler
|
|
80
|
+
handler = RotatingFileHandler('app.log', maxBytes=1000000, backupCount=5)
|
|
81
|
+
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
|
82
|
+
self.logger = logging.getLogger(__name__)
|
|
83
|
+
self.logger.setLevel(logging.INFO)
|
|
84
|
+
self.logger.addHandler(handler)
|
|
85
|
+
self.logger.info("Logging is set up.")
|
|
86
|
+
|
|
87
|
+
def setup_security(self):
|
|
88
|
+
"""Sets up authentication and rate limiting."""
|
|
89
|
+
# Authentication Decorator
|
|
90
|
+
def require_api_key(f):
|
|
91
|
+
@wraps(f)
|
|
92
|
+
def decorated(*args, **kwargs):
|
|
93
|
+
key = request.headers.get('x-api-key')
|
|
94
|
+
if not key or key != self.api_key:
|
|
95
|
+
self.logger.warning("Unauthorized access attempt detected.")
|
|
96
|
+
return jsonify({"status": "error", "message": "Unauthorized: Invalid or missing API key."}), 401
|
|
97
|
+
return f(*args, **kwargs)
|
|
98
|
+
return decorated
|
|
99
|
+
|
|
100
|
+
# Rate Limiting using Flask-Limiter
|
|
101
|
+
self.limiter = Limiter(
|
|
102
|
+
self.app,
|
|
103
|
+
key_func=get_remote_address,
|
|
104
|
+
default_limits=["200 per day", "50 per hour"]
|
|
105
|
+
)
|
|
106
|
+
self.logger.info("Rate limiting enabled: 200 requests/day and 50 requests/hour per IP.")
|
|
107
|
+
self.require_api_key = require_api_key # Attach to instance for use in routes
|
|
108
|
+
|
|
109
|
+
def setup_execution_environment(self):
|
|
110
|
+
"""Initializes the secure execution environment."""
|
|
111
|
+
self.globals_context = {
|
|
112
|
+
"___etny_result___": self.___etny_result___,
|
|
113
|
+
**sdkFunctions
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def setup_routes(self):
|
|
117
|
+
"""Defines Flask routes."""
|
|
118
|
+
@self.app.route('/execute', methods=['POST'])
|
|
119
|
+
@self.require_api_key
|
|
120
|
+
@self.limiter.limit("10 per minute") # Additional rate limit for this endpoint
|
|
121
|
+
def execute():
|
|
122
|
+
"""
|
|
123
|
+
Executes user-provided Python code and returns the outputs.
|
|
124
|
+
"""
|
|
125
|
+
data = request.get_json()
|
|
126
|
+
if not data:
|
|
127
|
+
self.logger.warning("No JSON payload received.")
|
|
128
|
+
return jsonify({"status": "error", "message": "Invalid JSON payload."}), 400
|
|
129
|
+
|
|
130
|
+
payload = data.get('payload')
|
|
131
|
+
input_data = data.get('input_data', None)
|
|
132
|
+
|
|
133
|
+
if not payload:
|
|
134
|
+
self.logger.warning("No 'payload' provided in the request.")
|
|
135
|
+
return jsonify({"status": "error", "message": "No payload provided."}), 400
|
|
136
|
+
|
|
137
|
+
# Execute the payload
|
|
138
|
+
result = execute_task(payload, input_data)
|
|
139
|
+
|
|
140
|
+
return jsonify(result)
|
|
141
|
+
|
|
142
|
+
@self.app.route('/reload_backend', methods=['POST'])
|
|
143
|
+
@self.require_api_key
|
|
144
|
+
def reload_backend():
|
|
145
|
+
"""
|
|
146
|
+
Reloads backend functions dynamically.
|
|
147
|
+
"""
|
|
148
|
+
self.logger.info("Reloading backend functions...")
|
|
149
|
+
global backend, sdkFunctions
|
|
150
|
+
try:
|
|
151
|
+
importlib.reload(backend) # Reload backend module
|
|
152
|
+
sdkFunctions = {}
|
|
153
|
+
if backend is not None:
|
|
154
|
+
for func in backend.__dict__.keys():
|
|
155
|
+
if func not in backend.__builtins__.keys() and func not in [
|
|
156
|
+
"__file__",
|
|
157
|
+
"__cached__",
|
|
158
|
+
"__builtins__",
|
|
159
|
+
]:
|
|
160
|
+
sdkFunctions.update({func: backend.__dict__[func]})
|
|
161
|
+
# Update globals_context with new sdkFunctions
|
|
162
|
+
self.globals_context.update(sdkFunctions)
|
|
163
|
+
self.logger.info("Backend functions reloaded successfully.")
|
|
164
|
+
return jsonify({"status": "success", "message": "Backend functions reloaded successfully."}), 200
|
|
165
|
+
except Exception as e:
|
|
166
|
+
self.logger.error(f"Failed to reload backend functions: {e}", exc_info=True)
|
|
167
|
+
return jsonify({"status": "error", "message": "Failed to reload backend functions."}), 500
|
|
168
|
+
|
|
169
|
+
@self.app.route('/health', methods=['GET'])
|
|
170
|
+
def health():
|
|
171
|
+
"""Health check endpoint."""
|
|
172
|
+
return jsonify({"status": "healthy"}), 200
|
|
173
|
+
|
|
174
|
+
@self.app.route('/list_functions', methods=['GET'])
|
|
175
|
+
@self.require_api_key
|
|
176
|
+
def list_functions():
|
|
177
|
+
"""
|
|
178
|
+
Lists all loaded backend functions.
|
|
179
|
+
"""
|
|
180
|
+
functions = {name: func.__doc__ for name, func in sdkFunctions.items() if callable(func)}
|
|
181
|
+
return jsonify({"status": "success", "functions": functions}), 200
|
|
182
|
+
|
|
183
|
+
def run_flask(self):
|
|
184
|
+
"""Runs the Flask server."""
|
|
185
|
+
self.app.run(port=self.local_port, debug=False, use_reloader=False)
|
|
186
|
+
|
|
187
|
+
def start_ngrok(self):
|
|
188
|
+
"""Starts ngrok tunnel to forward the specified port."""
|
|
189
|
+
try:
|
|
190
|
+
self.logger.info("Starting ngrok tunnel...")
|
|
191
|
+
# Authenticate with ngrok
|
|
192
|
+
ngrok.set_auth_token(os.getenv("NGROK_AUTHTOKEN"))
|
|
193
|
+
|
|
194
|
+
# Forward the port with TLS
|
|
195
|
+
public_url = ngrok.connect(self.local_port, bind_tls=True)
|
|
196
|
+
self.ngrok_url = public_url.public_url
|
|
197
|
+
self.logger.info(f"ngrok tunnel established at {self.ngrok_url}")
|
|
198
|
+
print(f"ngrok tunnel established at {self.ngrok_url}")
|
|
199
|
+
except Exception as e:
|
|
200
|
+
self.logger.error(f"Failed to start ngrok tunnel: {e}", exc_info=True)
|
|
201
|
+
|
|
202
|
+
def start(self):
|
|
203
|
+
"""Starts the Flask server and ngrok tunnel."""
|
|
204
|
+
self.server_thread.start()
|
|
205
|
+
self.ngrok_thread.start()
|
|
206
|
+
self.logger.info("Flask server and ngrok tunnel threads started.")
|
|
207
|
+
|
|
208
|
+
# ===========================
|
|
209
|
+
# Execution Functions
|
|
210
|
+
# ===========================
|
|
211
|
+
|
|
212
|
+
def ___etny_result___(self, data):
|
|
213
|
+
"""Handles the result of execution."""
|
|
214
|
+
# Here, you can implement any post-execution handling if needed
|
|
215
|
+
# For now, we'll simply return the data
|
|
216
|
+
return data
|
|
217
|
+
|
|
218
|
+
def ___etny_result___(data):
|
|
219
|
+
quit([0, data])
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def execute_task(payload_data, input_data):
|
|
223
|
+
return Exec(
|
|
224
|
+
payload_data,
|
|
225
|
+
input_data,
|
|
226
|
+
{"___etny_result___": ___etny_result___, **sdkFunctions},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def Exec(payload_data, input_data, globals=None, locals=None):
|
|
231
|
+
try:
|
|
232
|
+
if payload_data is not None:
|
|
233
|
+
if input_data is not None:
|
|
234
|
+
globals["___etny_data_set___"] = input_data
|
|
235
|
+
module = ast.parse(payload_data)
|
|
236
|
+
outputs = []
|
|
237
|
+
for node in module.body:
|
|
238
|
+
if isinstance(node, ast.Expr):
|
|
239
|
+
expr_code = compile(
|
|
240
|
+
ast.Expression(node.value), filename="<ast>", mode="eval"
|
|
241
|
+
)
|
|
242
|
+
result = eval(expr_code, globals, locals)
|
|
243
|
+
outputs.append(result)
|
|
244
|
+
else:
|
|
245
|
+
# Handle statements if needed
|
|
246
|
+
exec(
|
|
247
|
+
compile(ast.Module([node], type_ignores=[]), filename="<ast>", mode="exec"),
|
|
248
|
+
globals,
|
|
249
|
+
locals,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Function results are arbitrary Python values -- the shipped
|
|
253
|
+
# esr-counter example returns dicts -- so serialize anything that
|
|
254
|
+
# is not already a str instead of letting join() raise. None (an
|
|
255
|
+
# expression statement with no value, e.g. a bare print()) is
|
|
256
|
+
# dropped, matching REPL semantics.
|
|
257
|
+
rendered = [
|
|
258
|
+
out if isinstance(out, str) else json.dumps(out, default=str)
|
|
259
|
+
for out in outputs
|
|
260
|
+
if out is not None
|
|
261
|
+
]
|
|
262
|
+
return ___etny_result___("\n".join(rendered))
|
|
263
|
+
else:
|
|
264
|
+
return (
|
|
265
|
+
TaskStatus.PAYLOAD_NOT_DEFINED,
|
|
266
|
+
"Could not find the source file to execute",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return TaskStatus.SUCCESS, "TASK EXECUTED SUCCESSFULLY"
|
|
270
|
+
except SystemExit as e:
|
|
271
|
+
# A task returns its result ONLY through ___etny_result___(data) ->
|
|
272
|
+
# quit([code, data]) -> SystemExit carrying [code, data]. Honor that
|
|
273
|
+
# embedded code. Handled BEFORE SystemError: the read path's web3/crypto
|
|
274
|
+
# C-extensions intermittently raise a spurious SystemError during the
|
|
275
|
+
# SystemExit unwind, which previously stamped SYSTEM_ERROR onto a task
|
|
276
|
+
# that had already produced the correct value (~50% false read failure).
|
|
277
|
+
import traceback
|
|
278
|
+
try:
|
|
279
|
+
code, data = e.args[0][0], e.args[0][1]
|
|
280
|
+
except Exception:
|
|
281
|
+
tb = traceback.format_exc()
|
|
282
|
+
logging.error("SystemExit without [code, data]: %r -- %s", e, tb)
|
|
283
|
+
return TaskStatus.SYSTEM_ERROR, f"SYSTEM_ERROR: {e!r}\n{tb}"
|
|
284
|
+
if code == 0:
|
|
285
|
+
return TaskStatus.SUCCESS, data
|
|
286
|
+
return int(code), data
|
|
287
|
+
except SystemError as e:
|
|
288
|
+
# A spurious SystemError may replace the SystemExit mid-unwind; recover
|
|
289
|
+
# the real result from the chained exception before failing.
|
|
290
|
+
import traceback
|
|
291
|
+
for chained in (getattr(e, "__context__", None), getattr(e, "__cause__", None)):
|
|
292
|
+
if isinstance(chained, SystemExit):
|
|
293
|
+
try:
|
|
294
|
+
code, data = chained.args[0][0], chained.args[0][1]
|
|
295
|
+
except Exception:
|
|
296
|
+
continue
|
|
297
|
+
logging.warning(
|
|
298
|
+
"SystemError during result unwind; recovered result from "
|
|
299
|
+
"chained SystemExit (code=%r). Native error: %r", code, e)
|
|
300
|
+
if code == 0:
|
|
301
|
+
return TaskStatus.SUCCESS, data
|
|
302
|
+
return int(code), data
|
|
303
|
+
tb = traceback.format_exc()
|
|
304
|
+
logging.error("SystemError in payload execution: %r -- %s", e, tb)
|
|
305
|
+
return TaskStatus.SYSTEM_ERROR, f"SYSTEM_ERROR: {e!r}\n{tb}"
|
|
306
|
+
except KeyError as e:
|
|
307
|
+
return TaskStatus.KEY_ERROR, e.args[0]
|
|
308
|
+
except SyntaxWarning as e:
|
|
309
|
+
return TaskStatus.SYNTAX_WARNING, e.args[0]
|
|
310
|
+
except BaseException as e:
|
|
311
|
+
import traceback
|
|
312
|
+
try:
|
|
313
|
+
if e.args[0][0] == 0:
|
|
314
|
+
return TaskStatus.SUCCESS, e.args[0][1]
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
tb = traceback.format_exc()
|
|
318
|
+
logging.error("BaseException in payload execution: %r -- %s", e, tb)
|
|
319
|
+
return TaskStatus.BASE_EXCEPTION, f"BASE_EXCEPTION: {e!r}\n{tb}"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ===========================
|
|
323
|
+
# Main Execution
|
|
324
|
+
# ===========================
|
|
325
|
+
|
|
326
|
+
def main():
|
|
327
|
+
# Initialize and start the EthernityEnclaveServer
|
|
328
|
+
server = EthernityEnclaveServer(local_port=5000)
|
|
329
|
+
server.start()
|
|
330
|
+
|
|
331
|
+
# Wait briefly to ensure ngrok tunnel is established
|
|
332
|
+
time.sleep(2)
|
|
333
|
+
|
|
334
|
+
if not server.ngrok_url:
|
|
335
|
+
print("Failed to establish ngrok tunnel.")
|
|
336
|
+
sys.exit(1)
|
|
337
|
+
|
|
338
|
+
print("EthernityEnclaveServer is running!")
|
|
339
|
+
print(f"ngrok URL: {server.ngrok_url}")
|
|
340
|
+
print(f"API Key: {server.api_key}")
|
|
341
|
+
|
|
342
|
+
# Keep the main thread alive to continue listening for replies
|
|
343
|
+
try:
|
|
344
|
+
while True:
|
|
345
|
+
time.sleep(1)
|
|
346
|
+
except KeyboardInterrupt:
|
|
347
|
+
print("Shutting down the EthernityEnclaveServer.")
|
|
348
|
+
# Disconnect all ngrok tunnels
|
|
349
|
+
ngrok.kill()
|
|
350
|
+
sys.exit()
|
|
351
|
+
|
|
352
|
+
if __name__ == "__main__":
|
|
353
|
+
main()
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Interactive session executor for the securelock enclave.
|
|
2
|
+
|
|
3
|
+
Runs when the trustedzone staged 'session.config.securelock'. The payload
|
|
4
|
+
context is created once and kept alive; every streamed input is delivered
|
|
5
|
+
to the handler the payload registered:
|
|
6
|
+
|
|
7
|
+
def ___etny_on_input___(data):
|
|
8
|
+
...
|
|
9
|
+
return "reply"
|
|
10
|
+
|
|
11
|
+
A payload that defines no handler is not session-aware: each input is
|
|
12
|
+
answered with an explicit error output (no silent re-execution fallback),
|
|
13
|
+
so the dApp developer sees the problem on the first message.
|
|
14
|
+
|
|
15
|
+
Objects (all trustedzone<->securelock traffic uses the existing
|
|
16
|
+
encrypted+signed envelope in both directions):
|
|
17
|
+
session.config.securelock {order_id, close_after_seconds,
|
|
18
|
+
input_cutoff_seconds, max_messages}
|
|
19
|
+
session.input.<seq>.securelock one streamed input (+ .sig)
|
|
20
|
+
session.output.<n> {"ack": seq, "code": c, "data": out}
|
|
21
|
+
session.control.securelock 'close'
|
|
22
|
+
|
|
23
|
+
The TIMEOUT GUARD is the point of this module: the running period ending is
|
|
24
|
+
a COMPLETION, not a failure. The guard arms from the attested
|
|
25
|
+
close_after_seconds delta (monotonic clock -- the enclave never trusts the
|
|
26
|
+
node's wall clock), stops pulling inputs at the cutoff, gives an in-flight
|
|
27
|
+
handler a short grace, and finalizes through the ordinary result path with
|
|
28
|
+
task code SUCCESS and a session summary, e.g.:
|
|
29
|
+
|
|
30
|
+
{"session": "v1", "reason": "RUNNING_PERIOD_COMPLETE",
|
|
31
|
+
"seen": 12, "processed": 12, "emitted": 12, "unprocessed": []}
|
|
32
|
+
|
|
33
|
+
so a full-duration session reads as 'securelock running period complete'.
|
|
34
|
+
The hard-kill path only remains for a guard that never returns -- a genuine
|
|
35
|
+
fault, refunded via the operator-fault flow.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
import threading
|
|
41
|
+
import time
|
|
42
|
+
|
|
43
|
+
import etny_exec
|
|
44
|
+
from etny_exec import TaskStatus
|
|
45
|
+
|
|
46
|
+
HANDLER_NAME = "___etny_on_input___"
|
|
47
|
+
HANDLER_GRACE_SECONDS = 30
|
|
48
|
+
TICK_SECONDS = 2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _render(value):
|
|
52
|
+
if value is None:
|
|
53
|
+
return ""
|
|
54
|
+
return value if isinstance(value, str) else json.dumps(value, default=str)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_handler(base_globals):
|
|
58
|
+
"""The session input handler, from EITHER the namespaced `ecld.on_input`
|
|
59
|
+
or the legacy bare `___etny_on_input___`. `ecld.on_input` wins if both are
|
|
60
|
+
set; returns None if neither is a callable (handler-less payload)."""
|
|
61
|
+
ecld = base_globals.get("ecld")
|
|
62
|
+
if ecld is not None:
|
|
63
|
+
h = getattr(ecld, "on_input", None)
|
|
64
|
+
if callable(h):
|
|
65
|
+
return h
|
|
66
|
+
legacy = base_globals.get(HANDLER_NAME)
|
|
67
|
+
return legacy if callable(legacy) else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SecureLockSession:
|
|
71
|
+
def __init__(self, app):
|
|
72
|
+
self.app = app
|
|
73
|
+
cfg = json.loads(app.get_and_verify_trustedzone_object("session.config.securelock"))
|
|
74
|
+
self.order_id = int(cfg.get("order_id", -1))
|
|
75
|
+
self.max_messages = int(cfg.get("max_messages", 256))
|
|
76
|
+
started = time.monotonic()
|
|
77
|
+
self.close_at = started + float(cfg.get("close_after_seconds", 0))
|
|
78
|
+
self.cutoff_at = started + float(cfg.get("input_cutoff_seconds", 0))
|
|
79
|
+
self.seen = 0
|
|
80
|
+
self.processed = 0
|
|
81
|
+
self.emitted = 0
|
|
82
|
+
self.unprocessed = []
|
|
83
|
+
self.reason = "RUNNING_PERIOD_COMPLETE"
|
|
84
|
+
self.globals = etny_exec.session_base_globals()
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------ pieces
|
|
87
|
+
|
|
88
|
+
def _initial_run(self):
|
|
89
|
+
"""Create the payload context. Its own result is not a session output
|
|
90
|
+
-- setup code runs, a handler may get defined, state gets created."""
|
|
91
|
+
code, result = etny_exec.Exec(
|
|
92
|
+
self.app.payload_data, self.app.input_data, globals=self.globals)
|
|
93
|
+
logging.info(f"session: payload context created (code {code})")
|
|
94
|
+
if int(code) != int(TaskStatus.SUCCESS):
|
|
95
|
+
# A payload that cannot even set up cannot serve a session.
|
|
96
|
+
self.reason = "PAYLOAD_SETUP_FAILED"
|
|
97
|
+
return code, result
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def _handle(self, seq, data):
|
|
101
|
+
"""Deliver one input; returns (code, output). Runs the handler on a
|
|
102
|
+
worker thread so a hung payload cannot outlive the timeout guard."""
|
|
103
|
+
handler = _resolve_handler(self.globals)
|
|
104
|
+
if not callable(handler):
|
|
105
|
+
# No silent fallback: a payload without a handler is not
|
|
106
|
+
# session-aware, so every input gets an explicit, acked error.
|
|
107
|
+
return (int(TaskStatus.PAYLOAD_NOT_DEFINED),
|
|
108
|
+
"SESSION_HANDLER_NOT_DEFINED: this payload defines no "
|
|
109
|
+
"session input handler, so streamed inputs cannot be "
|
|
110
|
+
"processed. Set ecld.on_input = def handler(data): ... "
|
|
111
|
+
"(or the legacy ___etny_on_input___) in the payload and "
|
|
112
|
+
"republish.")
|
|
113
|
+
box = {}
|
|
114
|
+
|
|
115
|
+
def work():
|
|
116
|
+
try:
|
|
117
|
+
box["out"] = (int(TaskStatus.SUCCESS), _render(handler(data)))
|
|
118
|
+
except Exception as e:
|
|
119
|
+
box["out"] = (int(TaskStatus.SYSTEM_ERROR), f"SESSION HANDLER ERROR: {e}")
|
|
120
|
+
|
|
121
|
+
worker = threading.Thread(target=work, daemon=True)
|
|
122
|
+
worker.start()
|
|
123
|
+
budget = max(HANDLER_GRACE_SECONDS, self.close_at - time.monotonic())
|
|
124
|
+
worker.join(budget)
|
|
125
|
+
if worker.is_alive():
|
|
126
|
+
logging.error(f"session: handler hung on input {seq}; abandoning")
|
|
127
|
+
self.unprocessed.append(seq)
|
|
128
|
+
return None
|
|
129
|
+
return box.get("out", (int(TaskStatus.SYSTEM_ERROR), "SESSION HANDLER LOST"))
|
|
130
|
+
|
|
131
|
+
def _session_binding(self, base_name):
|
|
132
|
+
"""The prefix each session object's signature covers ahead of the
|
|
133
|
+
data: "<challenge>:<order_id>:<object>:", the task binding the
|
|
134
|
+
trustedzone forwarded in task.securelock extended with the object's
|
|
135
|
+
own name, so the trustedzone can refuse an object captured from
|
|
136
|
+
another order, another run, or another sequence position. Empty
|
|
137
|
+
without a binding (older trustedzone), which then verifies the bare
|
|
138
|
+
data."""
|
|
139
|
+
binding = getattr(self.app, "task_binding", None)
|
|
140
|
+
if not binding:
|
|
141
|
+
return ""
|
|
142
|
+
return f"{binding['challenge']}:{binding['order_id']}:{base_name}:"
|
|
143
|
+
|
|
144
|
+
def _emit(self, ack_seq, code, data):
|
|
145
|
+
envelope = json.dumps({"ack": ack_seq, "code": int(code), "data": data})
|
|
146
|
+
name = f"session.output.{self.emitted}"
|
|
147
|
+
self.app.encrypt_file_and_push_to_swifstream(
|
|
148
|
+
envelope, name, signed_prefix=self._session_binding(name))
|
|
149
|
+
self.emitted += 1
|
|
150
|
+
|
|
151
|
+
def _close_requested(self):
|
|
152
|
+
status, _ = self.app.swift_stream_service.is_object_in_bucket(
|
|
153
|
+
self.app.etny_bucket, "session.control.securelock")
|
|
154
|
+
if not status:
|
|
155
|
+
return False
|
|
156
|
+
# An unauthenticated close signal is ignored: the node could place an
|
|
157
|
+
# object in the bucket, but only the trustedzone can SIGN one.
|
|
158
|
+
try:
|
|
159
|
+
control = self.app.get_and_verify_trustedzone_object("session.control.securelock")
|
|
160
|
+
return str(control).strip() == "close"
|
|
161
|
+
except Exception:
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
# --------------------------------------------------------------------- run
|
|
165
|
+
|
|
166
|
+
def run(self):
|
|
167
|
+
setup_failure = self._initial_run()
|
|
168
|
+
if setup_failure is not None:
|
|
169
|
+
return setup_failure
|
|
170
|
+
# READINESS HANDSHAKE: tell the trustedzone this securelock speaks the
|
|
171
|
+
# session protocol AND whether the payload defined an input handler.
|
|
172
|
+
# A pre-session securelock never writes this (the trustedzone answers
|
|
173
|
+
# every input with an explicit error), and a handler-less payload is
|
|
174
|
+
# announced by the trustedzone as a signed code-5 error row on the
|
|
175
|
+
# DP-request metadata side before any input is even sent.
|
|
176
|
+
try:
|
|
177
|
+
ready = json.dumps({
|
|
178
|
+
"ready": True,
|
|
179
|
+
"handler": callable(_resolve_handler(self.globals)),
|
|
180
|
+
})
|
|
181
|
+
self.app.encrypt_file_and_push_to_swifstream(
|
|
182
|
+
ready, "session.ready",
|
|
183
|
+
signed_prefix=self._session_binding("session.ready"))
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logging.error(f"session: could not publish readiness: {e}")
|
|
186
|
+
next_seq = 0
|
|
187
|
+
while True:
|
|
188
|
+
now = time.monotonic()
|
|
189
|
+
if now >= self.close_at:
|
|
190
|
+
self.reason = "RUNNING_PERIOD_COMPLETE"
|
|
191
|
+
break
|
|
192
|
+
if self._close_requested():
|
|
193
|
+
self.reason = "CLOSED"
|
|
194
|
+
break
|
|
195
|
+
if next_seq >= self.max_messages:
|
|
196
|
+
self.reason = "MESSAGE_CAP"
|
|
197
|
+
break
|
|
198
|
+
obj = f"session.input.{next_seq}.securelock"
|
|
199
|
+
status, _ = self.app.swift_stream_service.is_object_in_bucket(
|
|
200
|
+
self.app.etny_bucket, obj)
|
|
201
|
+
if not status:
|
|
202
|
+
time.sleep(TICK_SECONDS)
|
|
203
|
+
continue
|
|
204
|
+
if now >= self.cutoff_at:
|
|
205
|
+
# Delivered past the cutoff: the trustedzone answers these
|
|
206
|
+
# with signed late notices; we only stop consuming.
|
|
207
|
+
self.reason = "RUNNING_PERIOD_COMPLETE"
|
|
208
|
+
break
|
|
209
|
+
try:
|
|
210
|
+
data = self.app.get_and_verify_trustedzone_object(obj)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logging.error(f"session: input {next_seq} failed verification: {e}")
|
|
213
|
+
time.sleep(TICK_SECONDS)
|
|
214
|
+
continue
|
|
215
|
+
self.seen += 1
|
|
216
|
+
outcome = self._handle(next_seq, data)
|
|
217
|
+
if outcome is not None:
|
|
218
|
+
code, out = outcome
|
|
219
|
+
# Only successful handling counts as processed; error outputs
|
|
220
|
+
# (handler missing / handler raised) are still emitted+acked
|
|
221
|
+
# so the dApp sees them, but the summary stays truthful.
|
|
222
|
+
if int(code) == int(TaskStatus.SUCCESS):
|
|
223
|
+
self.processed += 1
|
|
224
|
+
self._emit(next_seq, code, out)
|
|
225
|
+
next_seq += 1
|
|
226
|
+
summary = {
|
|
227
|
+
"session": "v1",
|
|
228
|
+
"reason": self.reason,
|
|
229
|
+
"seen": self.seen,
|
|
230
|
+
"processed": self.processed,
|
|
231
|
+
"emitted": self.emitted,
|
|
232
|
+
"unprocessed": self.unprocessed,
|
|
233
|
+
}
|
|
234
|
+
logging.info(f"session: finalizing -- {summary}")
|
|
235
|
+
return (TaskStatus.SUCCESS, json.dumps(summary))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def run_session(app):
|
|
239
|
+
"""Entry point used by securelock.execute(). Any unexpected failure
|
|
240
|
+
degrades to an ordinary task error, never a hang."""
|
|
241
|
+
try:
|
|
242
|
+
return SecureLockSession(app).run()
|
|
243
|
+
except Exception as e:
|
|
244
|
+
logging.error(f"session executor failed: {e}")
|
|
245
|
+
return (TaskStatus.SYSTEM_ERROR, f"SESSION EXECUTOR ERROR: {e}")
|
|
Binary file
|