@ethernity-cloud/sdk-js 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +44 -6
  2. package/cas/config.js +61 -3
  3. package/info.js +333 -324
  4. package/init.js +20 -4
  5. package/nodenithy/build/securelock/Dockerfile.tmpl +34 -32
  6. package/nodenithy/build/securelock/scripts/binary-fs-build.sh +7 -6
  7. package/nodenithy/build/securelock/src/securelock.js.tmpl +5 -2
  8. package/nodenithy/build.mjs +83 -71
  9. package/nodenithy/run/docker-compose-final.yml.tmpl +36 -31
  10. package/nodenithy/run/docker-compose.yml.tmpl +34 -45
  11. package/nodenithy/run/image_registry.js +396 -380
  12. package/nodenithy/run.js +42 -28
  13. package/package.json +5 -3
  14. package/publish.js +4 -2
  15. package/pynithy/build/securelock/Dockerfile.base.tpl +13 -0
  16. package/pynithy/build/securelock/Dockerfile.tpl +131 -0
  17. package/pynithy/build/securelock/scripts/binary-fs-build.sh +25 -34
  18. package/pynithy/build/securelock/scripts/pyfreeze.sh +32 -0
  19. package/pynithy/build/securelock/src/{pox.abi → abi/etnyImplementationV2.abi} +1489 -1489
  20. package/pynithy/build/securelock/src/abi/image_registry.abi +1 -0
  21. package/pynithy/build/securelock/src/ecld_state.py +906 -0
  22. package/pynithy/build/securelock/src/esr.abi +1 -0
  23. package/pynithy/build/securelock/src/esr_local.py +267 -0
  24. package/pynithy/build/securelock/src/esr_wallet.py +150 -0
  25. package/pynithy/build/securelock/src/etny_crypto.py +208 -167
  26. package/pynithy/build/securelock/src/etny_exec.py +385 -79
  27. package/pynithy/build/securelock/src/etny_exec_flask.py +535 -0
  28. package/pynithy/build/securelock/src/etny_exec_serv.py +353 -0
  29. package/pynithy/build/securelock/src/etny_session_exec.py +245 -0
  30. package/pynithy/build/securelock/src/get_sgx_report.so +0 -0
  31. package/pynithy/build/securelock/src/image_registry.abi +1 -1
  32. package/pynithy/build/securelock/src/key_generation.py +17 -17
  33. package/pynithy/build/securelock/src/local_api.py +189 -0
  34. package/pynithy/build/securelock/src/models.py +200 -176
  35. package/pynithy/build/securelock/src/securelock.py.tmpl +1037 -727
  36. package/pynithy/build/securelock/src/swift_stream_service.py +206 -186
  37. package/pynithy/build.mjs +142 -92
  38. package/pynithy/run/docker-compose-final.yml.tmpl +58 -51
  39. package/pynithy/run/docker-compose.yml.tmpl +62 -48
  40. package/pynithy/run/etny-securelock-test.yaml.tpl +4 -1
  41. package/pynithy/run/image_registry.js +396 -380
  42. package/pynithy/run.js +44 -28
  43. package/pynithy/src/serverless/Dockerfile.serverless +8 -0
  44. package/pynithy/src/serverless/requirements.txt +6 -0
  45. package/run.js +69 -28
  46. package/nodenithy/build/las/Dockerfile +0 -5
  47. package/pynithy/build/docker-compose.yml +0 -70
  48. package/pynithy/build/las/Dockerfile +0 -11
  49. package/pynithy/build/las/src/etny_entrypoint.sh +0 -6
  50. package/pynithy/build/las/src/las_entrypoint.sh +0 -26
  51. package/pynithy/build/scripts/binary-fs-build.sh +0 -28
  52. package/pynithy/build/securelock/Dockerfile.tmpl +0 -95
@@ -1,727 +1,1037 @@
1
- #!/usr/bin/python
2
-
3
- # update 29.12.2022
4
- import binascii
5
- import hashlib
6
- import os
7
- import re
8
- import string
9
- import random
10
- import io
11
- import time
12
- import base64 # CAS self-attestation quote decoding
13
- import json
14
- import secrets # CAS attestation challenge nonce
15
- import urllib.request # enclave-initiated CAS attestation endpoint
16
-
17
- from etny_crypto import etny_crypto as crypto
18
- from web3 import Web3
19
- from eth_account import Account
20
- from web3.middleware import geth_poa_middleware
21
- from key_generation import get_wallet_address
22
- from etny_exec import execute_task, TaskStatus
23
- from models import *
24
- from swift_stream_service import SwiftStreamService
25
- from eth_account.messages import defunct_hash_message
26
-
27
-
28
- # todo: remove EtnyResultPoc.debug
29
- class EtnySecureLock:
30
- debug = False
31
-
32
- def __init__(self, swiftStreamClient):
33
- self.swift_stream_service = swiftStreamClient
34
- self.version = 'v3'
35
- print(f'ETNY Pynithy [{self.version}]')
36
- self.__set_initializers()
37
- self.save_pub_cert()
38
- self.__load_env()
39
- # self.read_client_challenge()
40
- # self.generate_challenge()
41
- # self.generate_eth_compatible_wallet()
42
- # print('wallet = ', self.publickey)
43
- # print('private = ', self.privatekey)
44
- self.read_contract_abi()
45
- self.init_web3()
46
- self.verify_cas_attestation()
47
- self.print_env()
48
- # if not EtnySecureLock.debug:
49
- # self.reset_cert_file()
50
-
51
- @staticmethod
52
- def read_env(env_file):
53
- with open(env_file, 'r') as f:
54
- # Read each line of the file
55
- for line in f:
56
- # Split the line at the first '=' character
57
- key, value = line.split('=', 1)
58
- # Set the environment variable
59
- os.environ[key] = value
60
-
61
- @staticmethod
62
- def read_env_str(env_str):
63
- with io.StringIO(env_str) as f:
64
- # Read each line of the string
65
- for line in f:
66
- # Split the line at the first '=' character
67
- key, value = line.split('=', 1)
68
- # Set the environment variable and remove new line
69
- os.environ[key] = re.sub(r'\n', '', value)
70
-
71
- def __extract_signer(self, checksum, signature):
72
- signer = self.w3.eth.account.recoverHash(defunct_hash_message(text=checksum), signature=signature)
73
- return signer
74
-
75
- def __set_initializers(self):
76
- self.is_valid_client_data = True
77
- self.key_file = "/private/__ENCLAVE_NAME_SECURELOCK__/key.pem"
78
- self.cert_file = "/app/__ENCLAVE_NAME_SECURELOCK__/cert.pem"
79
- self.pub_cert_file = "/app/__ENCLAVE_NAME_SECURELOCK__/enclave_pub_cert.pem"
80
- self.payload = 'payload.etny.securelock'
81
- self.input = 'input.txt.securelock'
82
- self.result_file = '/app/result.txt'
83
- self.transaction_file = '/app/transaction.txt'
84
- self.smart_contract_address = '__SMART_CONTRACT_ADDRESS__'
85
- self.image_registry_address = '__IMAGE_REGISTRY_ADDRESS__'
86
- # ethernity-cas ValidatorRegistry, substituted at BUILD (never CAS- or
87
- # session-supplied: a rogue CAS must not choose the registry that
88
- # judges it). Empty = skip CAS self-attestation.
89
- self.validator_registry_address = '__VALIDATOR_REGISTRY_ADDRESS__'
90
- self.chain_id = __CHAIN_ID__
91
- self.web3_provider = '__RPC_URL__'
92
- self.etny_bucket = "__BUCKET_NAME__"
93
- self.trusted_zone_public_key = ''
94
- if EtnySecureLock.debug:
95
- self.key_file = "./app/cert1-ca1-clean.key"
96
- self.cert_file = "./app/cert1-ca1-clean.crt"
97
- self.payload = 'payload.py'
98
- self.input = 'input.txt'
99
- self.result_file = './app/result.txt'
100
- self.transaction_file = './app/transaction.txt'
101
- self.pub_cert_file = "./app/enclave_pub_cert.pem"
102
- # self.trustedzone_public_key = "./app/enclave_pub_cert.pem"
103
-
104
- def validate_client_payload(self, payload_data, input_data):
105
- self.get_do_request_metadata()
106
- if self._metadata.payload_metadata_obj.checksum is not None:
107
- #print('Computing payload checksum: ')
108
- payload_checksum = self.compute_sha256_checksum(payload_data)
109
- #print('payload checksum: ', payload_checksum)
110
- # check the wallet address that signed payload checksum is the one from order metadata
111
- payload_signer = self.__extract_signer(payload_checksum, self._metadata.payload_metadata_obj.checksum)
112
- if payload_signer.lower() != self.order_metadata.do_owner.lower():
113
- self.task_code = TaskStatus.PAYLOAD_CHECKSUM_ERROR
114
- self.task_result = 'PAYLOAD CHECKSUM DOESN\'T MATCH'
115
- self.is_valid_client_data = False
116
- return
117
-
118
- if self._metadata.input_metadata_obj.checksum is not None:
119
- input_checksum = self.compute_sha256_checksum(input_data)
120
- #print('input checksum: ', input_checksum)
121
- # check the wallet address that signed input checksum is the one from order metadata
122
- input_signer = self.__extract_signer(input_checksum, self._metadata.input_metadata_obj.checksum)
123
- if input_signer.lower() != self.order_metadata.do_owner.lower():
124
- self.task_code = TaskStatus.INPUT_CHECKSUM_ERROR
125
- self.task_result = 'INPUT CHECKSUM DOESN\'T MATCH'
126
- self.is_valid_client_data = False
127
- return
128
-
129
- def wait_for_payload_and_input(self):
130
- print('Waiting for payload and input')
131
- self.wait_for_trustedzone(self.etny_bucket, self.payload)
132
- self.wait_for_trustedzone(self.etny_bucket, self.input)
133
-
134
- def wait_for_trustedzone(self, bucket_name, object_name, timeout=3600):
135
- i = 0
136
- print(f'Checking if object {object_name} exists in bucket {bucket_name}')
137
- while True:
138
- time.sleep(1)
139
- i = i + 1
140
- if i > timeout:
141
- break
142
- (status, result) = self.swift_stream_service.is_object_in_bucket(bucket_name, object_name)
143
- if status:
144
- break
145
-
146
- print('secure lock finished the execution')
147
-
148
- def get_do_request_metadata(self):
149
- order = Order(self.etny.caller()._getOrder(self.order_id), self.order_id)
150
- #print('order', order)
151
- self._metadata = DOReqMetadata(self.etny.caller()._getDORequestMetadata(order.do_req), order.do_req)
152
- self.order_metadata = order
153
- #print('metadata', self._metadata)
154
-
155
- def compute_sha256_checksum(self, file_data):
156
- if type(file_data) is str:
157
- file_data = file_data.encode('utf-8')
158
-
159
- return hashlib.sha256(file_data).hexdigest()
160
-
161
- # h = hashlib.sha256()
162
- # file = io.BytesIO(file_data)
163
- # while True:
164
- # Reading is buffered, so we can read smaller chunks.
165
- # chunk = file.read(h.block_size)
166
- # if not chunk:
167
- # break
168
- # h.update(chunk)
169
- # return h.hexdigest()
170
-
171
- def reset_cert_file(self):
172
- #print('Resetting the cert file')
173
- try:
174
- with open(self.key_file, 'r+') as f:
175
- f.truncate(0)
176
- except Exception as e:
177
- print('Error while removing the private key file content', e)
178
-
179
- #with open(self.key_file, 'r') as f:
180
- # print('cert file content', f.read(), '##################')
181
-
182
- def generate_eth_compatible_wallet(self):
183
- decrypted_string = crypto.decrypt(
184
- private_key_file=self.key_file,
185
- encrypted_msg=self.encrypted_tuple
186
- )
187
- #print('decrypted_string = ', decrypted_string)
188
- (public, private) = get_wallet_address(decrypted_string.decode(), self.enclave_challenge)
189
- self.publickey = public
190
- self.privatekey = private
191
-
192
- def print_env(self):
193
- print('chain id:', self.chain_id)
194
- print('smart contract address:', self.smart_contract_address)
195
- print('web3 provider:', self.web3_provider)
196
- print('encrypted challenge:', self.client_challenge)
197
- print('order id:', self.order_id)
198
-
199
- def init_web3(self):
200
- self.w3 = Web3(Web3.HTTPProvider(self.web3_provider))
201
- self.w3.middleware_onion.inject(geth_poa_middleware, layer=0)
202
-
203
- # self.acct = Account.privateKeyToAccount(self.privatekey)
204
- self.etny = self.w3.eth.contract(address=self.w3.toChecksumAddress(self.smart_contract_address),
205
- abi=self.contract_abi)
206
- self.image_registry = self.w3.eth.contract(address=self.w3.toChecksumAddress(self.image_registry_address),
207
- abi=self.image_registry_abi)
208
-
209
- def _cas_endpoints_from_chain(self, cas_address):
210
- """Endpoints for a CAS, read FROM CHAIN -- never from the environment.
211
-
212
- The node operator controls the host and everything the enclave is
213
- handed, so the address to dial must come from the ValidatorRegistry
214
- itself. /onion3 is preferred, then the rest, matching the node-side
215
- resolver. Returns REST base URLs.
216
- """
217
- # Endpoints live in the CasKeyStore the registry names via keyStore();
218
- # the registry's own forwarders were removed when it reached the
219
- # EIP-170 size limit.
220
- registry_abi = [
221
- {"name": "keyStore", "type": "function", "stateMutability": "view",
222
- "inputs": [], "outputs": [{"type": "address"}]},
223
- ]
224
- store_abi = [
225
- {"name": "multiaddrsOf", "type": "function", "stateMutability": "view",
226
- "inputs": [{"type": "address"}], "outputs": [{"type": "string[]"}]},
227
- ]
228
- try:
229
- vr = self.w3.eth.contract(
230
- address=self.w3.toChecksumAddress(self.validator_registry_address),
231
- abi=registry_abi)
232
- store = self.w3.eth.contract(address=vr.caller().keyStore(), abi=store_abi)
233
- mas = store.caller().multiaddrsOf(self.w3.toChecksumAddress(cas_address))
234
- except Exception as e:
235
- print(f'CAS attestation: reading the CAS endpoints from chain failed: {e}')
236
- return []
237
- onion, rest = [], []
238
- for ma in mas:
239
- parts = [p for p in str(ma).split('/') if p]
240
- if len(parts) != 4 or parts[2] != 'tcp':
241
- continue
242
- kind, host, port = parts[0], parts[1], parts[3]
243
- try:
244
- port = int(port)
245
- except ValueError:
246
- continue
247
- # REST pairing convention: rest = 8081 + (enclave - 18765)
248
- rest_port = 8081 + (port - 18765) if 18765 <= port < 18965 else 8081
249
- if kind == 'onion3':
250
- host = host if host.endswith('.onion') else host + '.onion'
251
- onion.append(f'http://{host}:{rest_port}')
252
- elif kind in ('dns4', 'dns6', 'dns', 'ip4', 'ip6'):
253
- rest.append(f'http://{host}:{rest_port}')
254
- return onion + rest
255
-
256
- def challenge_cas(self, cas_address, registered_mrenclave, registered_cert):
257
- """
258
- ENCLAVE-INITIATED CAS attestation: prove the CAS is live NOW.
259
-
260
- The CAS is remote and the operator sits on the path, so the injected
261
- environment alone cannot prove liveness. The CAS minted a RANDOM
262
- session id (ECAS_ATTEST_SESSION) it holds OPEN; we resolve its
263
- endpoint FROM CHAIN, present that id with a nonce WE choose, and it
264
- returns a FRESH quote binding
265
- sha256(session || nonce || cas_addr || cert_hash). A captured
266
- environment stops working when its session closes; a captured
267
- RESPONSE cannot match a different nonce. Returns None on success,
268
- else the failure reason.
269
- """
270
- session_id = os.environ.get('ECAS_ATTEST_SESSION', '')
271
- if not session_id:
272
- return 'no ECAS_ATTEST_SESSION in the environment (CAS cannot be challenged)'
273
- nonce = secrets.token_bytes(32)
274
- # Echo back the nonce the CAS injected, so the SAME fresh quote also
275
- # re-attests the environment we were handed (report_data[52:64]). The
276
- # CAS recomputes the injected commitment from its own address and cert
277
- # -- we supply only the nonce -- so this cannot be steered.
278
- env_nonce_hex = os.environ.get('ECAS_ENCLAVE_NONCE', '')
279
- payload = json.dumps({'session': session_id, 'nonce': nonce.hex(),
280
- 'env_nonce': env_nonce_hex}).encode()
281
-
282
-
283
-
284
- endpoints = self._cas_endpoints_from_chain(cas_address)
285
- if not endpoints:
286
- return 'the CAS publishes no dialable endpoint on chain'
287
- last = 'no endpoint answered'
288
- for base in endpoints:
289
- url = f'{base}/attest/session'
290
- try:
291
- req = urllib.request.Request(
292
- url, data=payload, headers={'Content-Type': 'application/json'})
293
- with urllib.request.urlopen(req, timeout=20) as r:
294
- doc = json.loads(r.read().decode('utf-8', 'replace'))
295
- except Exception as e:
296
- last = f'{url}: {e}'
297
- continue
298
- try:
299
- quote = bytes.fromhex(doc.get('quote', ''))
300
- except ValueError:
301
- return f'{url}: challenge quote is not hex'
302
- if len(quote) < 48 + 384:
303
- return f'{url}: challenge quote too short ({len(quote)} bytes)'
304
- rd = quote[368:432]
305
- got_addr = '0x' + rd[32:52].hex()
306
- if got_addr.lower() != cas_address.lower():
307
- return (f'challenge quote names {got_addr}, not the CAS '
308
- f'{cas_address} we verified on chain')
309
- expect = hashlib.sha256(
310
- session_id.encode() + nonce
311
- + bytes.fromhex(cas_address[2:]) + bytes.fromhex(registered_cert)
312
- ).digest()
313
- if rd[0:32] != expect:
314
- return ('challenge commitment mismatch: the CAS did not sign '
315
- 'our session+nonce (stale or replayed response)')
316
- # ENVIRONMENT RE-ATTESTATION. When we sent env_nonce, the fresh
317
- # quote must also carry the head of the commitment that went into
318
- # ECAS_CAS_QUOTE. This is what ties the two quotes together: a live
319
- # CAS confirming, under a NEW signature, that the environment we
320
- # hold is the one it sent. Without it the challenge proved only
321
- # that some CAS was alive.
322
- if env_nonce_hex:
323
- env_expect = hashlib.sha256(
324
- session_id.encode() + bytes.fromhex(env_nonce_hex)
325
- + bytes.fromhex(cas_address[2:])
326
- + bytes.fromhex(registered_cert)).digest()
327
- if rd[52:64] != env_expect[0:12]:
328
- return ('the CAS did not confirm the environment it sent: '
329
- f'fresh quote carries {rd[52:64].hex()}, expected '
330
- f'{env_expect[0:12].hex()}')
331
- print('CAS re-attested the injected environment under a '
332
- 'fresh signature')
333
- qmr = quote[112:144].hex()
334
- if qmr.lower() != registered_mrenclave.lower():
335
- return (f'challenge quote MRENCLAVE {qmr} != registered '
336
- f'{registered_mrenclave}')
337
- print(
338
- f'CAS CHALLENGE PASSED: {base} signed our session+nonce with a '
339
- f'fresh quote (MRENCLAVE {qmr[:16]}...)')
340
- return None
341
- return f'CAS challenge failed: {last}'
342
-
343
- def verify_cas_attestation(self):
344
- """
345
- Verify the CAS that provisioned this enclave. The CAS injects
346
- ECAS_CAS_QUOTE (a DCAP quote binding its EGETKEY validator address at
347
- report_data[0:20] and the SHA-256 of its certificate at [20:52]); we
348
- check it against the ValidatorRegistry baked into this build: active
349
- validator, registered MRENCLAVE match, registered certHash match.
350
- Evicting a CAS on-chain revokes it here on the next task. STRICT: a
351
- missing quote, an invalid one, and an unreachable registry are all
352
- the same terminal fault (refund); there is no softening knob. The quote's ECDSA/PCK
353
- chain is not verified in-enclave yet; the binding is quote-contents
354
- <-> on-chain record (the revocation path).
355
- """
356
- registry = self.validator_registry_address
357
- self.cas_attestation_fault = None
358
-
359
- def fault(reason):
360
- # TERMINAL: registry present -> a CAS quote is MANDATORY, so a
361
- # MISSING quote is as damning as a wrong one. The operator provisioned this task through an
362
- # impostor CAS. Recorded here and adjudicated before the payload
363
- # runs -- the task then terminates with CAS_ATTESTATION_FAULT
364
- # (40) and the order is refunded.
365
- print(f'CAS ATTESTATION INVALID: {reason}')
366
- self.cas_attestation_fault = reason
367
-
368
- if not registry or registry.startswith('__'):
369
- print('CAS attestation: no ValidatorRegistry for this network; skipping')
370
- return
371
- quote_b64 = os.environ.get('ECAS_CAS_QUOTE', '')
372
- if not quote_b64:
373
- return fault('no ECAS_CAS_QUOTE in the session environment (unattested CAS)')
374
- try:
375
- quote = base64.b64decode(quote_b64)
376
- except Exception as e:
377
- return fault(f'ECAS_CAS_QUOTE is not valid base64: {e}')
378
- # DCAP quote layout: 48-byte header; report body: ATTRIBUTES at
379
- # body+48 (16), MRENCLAVE at body+64 (32), report_data at body+320 (64).
380
- if len(quote) < 48 + 384:
381
- return fault(f'quote too short ({len(quote)} bytes)')
382
- attributes = quote[96:112]
383
- quote_mrenclave = quote[112:144].hex()
384
- report_data = quote[368:432]
385
-
386
- vr_abi = [
387
- {"name": "isValidator", "type": "function", "stateMutability": "view",
388
- "inputs": [{"type": "address"}], "outputs": [{"type": "bool"}]},
389
- # The registry's Validator struct: the measurement is `bytes` at
390
- # its platform width (32 for SGX MRENCLAVE), not bytes32.
391
- {"name": "validators", "type": "function", "stateMutability": "view",
392
- "inputs": [{"type": "address"}],
393
- "outputs": [{"name": "measurement", "type": "bytes"},
394
- {"name": "certHash", "type": "bytes32"},
395
- {"name": "active", "type": "bool"},
396
- {"name": "admittedBlock", "type": "uint64"},
397
- {"name": "lastVoteBlock", "type": "uint64"},
398
- {"name": "teeType", "type": "uint16"},
399
- {"name": "operator", "type": "address"}]},
400
- ]
401
- last_err = None
402
- cas_address, bound_cert, active, record = None, None, False, None
403
- for _attempt in range(3):
404
- try:
405
- vr = self.w3.eth.contract(address=self.w3.toChecksumAddress(registry),
406
- abi=vr_abi)
407
- # Whichever layout names an ACTIVE validator is the real one.
408
- for cand_addr, cand_cert in cas_candidates:
409
- if vr.caller().isValidator(cand_addr):
410
- cas_address, bound_cert = cand_addr, cand_cert
411
- active = True
412
- record = vr.caller().validators(cand_addr)
413
- break
414
- if not active:
415
- # Both layouts resolved to non-validators: report the
416
- # fresh-layout address, which is what a current CAS sends.
417
- cas_address, bound_cert = cas_candidates[0]
418
- last_err = None
419
- break
420
- except Exception as e:
421
- last_err = e
422
- time.sleep(5)
423
- if last_err is not None:
424
- # The enclave already depends on this chain for its order
425
- # metadata, so an unreachable registry is not a new fragility --
426
- # and treating it softly would let an operator bypass the check
427
- # by firewalling the RPC.
428
- return fault(f'ValidatorRegistry {registry} unreachable after 3 attempts: {last_err}')
429
- if not active:
430
- return fault(f'CAS {cas_address} is NOT an active validator')
431
- registered_mr = record[0].hex()
432
- if registered_mr != quote_mrenclave:
433
- return fault(f'CAS quote MRENCLAVE {quote_mrenclave} != registered {registered_mr}')
434
- registered_cert = record[1].hex()
435
- if bound_cert is not None:
436
- # Legacy layout: the cert hash is a readable field, compare it.
437
- if registered_cert != '00' * 32 and registered_cert != bound_cert:
438
- return fault(f'CAS quote cert hash {bound_cert} != registered {registered_cert}')
439
- else:
440
- # FRESH layout: the cert hash is not a readable field -- it is
441
- # folded into report_data[0:32] together with the attestation
442
- # SESSION ID and this enclave's own nonce. Recomputing that
443
- # commitment is what binds the injected ENVIRONMENT AS ONE UNIT:
444
- # ECAS_ATTEST_SESSION, ECAS_ENCLAVE_NONCE and ECAS_CAS_QUOTE only
445
- # agree if the CAS minted all three together for THIS session. An
446
- # operator who strips the session id, swaps in another enclave's
447
- # id, or pairs a captured quote with a fresh session breaks the
448
- # hash -- and cannot repair it, because the commitment is inside
449
- # an SGX signature.
450
- #
451
- # The nonce is echoed by the CAS because SCONE gives the enclave
452
- # no way to read its own attestation report_data. Echoing costs
453
- # nothing: a tampered nonce simply fails to reproduce [0:32].
454
- if registered_cert == '00' * 32:
455
- # Without a registered cert there is nothing to recompute
456
- # against, so the binding cannot be proven. Tolerating that
457
- # would hand every CAS an opt-out: leave certHash unset and
458
- # the strongest check silently disappears.
459
- return fault(f'CAS {cas_address} has no certHash registered; '
460
- 'the fresh quote binding cannot be verified')
461
- session_id = os.environ.get('ECAS_ATTEST_SESSION', '')
462
- nonce_hex = os.environ.get('ECAS_ENCLAVE_NONCE', '')
463
- if not session_id:
464
- return fault('fresh CAS quote but no ECAS_ATTEST_SESSION '
465
- '(environment was tampered with)')
466
- if not nonce_hex:
467
- return fault('fresh CAS quote but no ECAS_ENCLAVE_NONCE '
468
- '(environment was tampered with)')
469
- try:
470
- nonce = bytes.fromhex(nonce_hex)
471
- except ValueError as e:
472
- return fault(f'ECAS_ENCLAVE_NONCE is not hex: {e}')
473
- expected = hashlib.sha256(
474
- session_id.encode()
475
- + nonce
476
- + bytes.fromhex(cas_address[2:])
477
- + bytes.fromhex(registered_cert)).digest()
478
- if expected != report_data[0:32]:
479
- return fault(
480
- 'CAS quote does not commit to the injected environment '
481
- f'(session {session_id[:16]}..., expected '
482
- f'{expected.hex()[:16]}..., quote carries '
483
- f'{report_data[0:32].hex()[:16]}...)')
484
- print('CAS environment binding OK: quote commits to '
485
- f'attestation session {session_id[:16]}...')
486
- # SGX ATTRIBUTES.FLAGS bit 1 = DEBUG. Tolerated only when the session
487
- # explicitly says so (testnet debug CAS).
488
- if (attributes[0] & 0x02) and '__NETWORK_TYPE__' != 'testnet':
489
- return fault('CAS enclave runs in SGX DEBUG mode')
490
- print(f'CAS ATTESTATION VERIFIED: validator {cas_address}, '
491
- f'MRENCLAVE {quote_mrenclave[:16]}..., certificate bound on-chain')
492
-
493
- # LIVENESS. Everything above proves the injected quote matches an
494
- # on-chain record -- but the environment was handed to us by the node.
495
- # Challenge the CAS ourselves over an endpoint resolved FROM CHAIN,
496
- # with a nonce we pick, so nothing replayed can pass. Same terminal
497
- # fault (refund) as any other CAS-attestation failure.
498
- challenge_error = self.challenge_cas(
499
- cas_address, registered_mr, registered_cert)
500
- if challenge_error is not None:
501
- return fault(challenge_error)
502
-
503
- def read_contract_abi(self):
504
- self.contract_abi = self.__read_contract_abi('pox.abi')
505
- self.image_registry_abi = self.__read_contract_abi('image_registry.abi')
506
-
507
- def __read_contract_abi(self, contract_name):
508
- f = open(os.path.dirname(os.path.realpath(__file__)) + f'/{contract_name}')
509
- contract_abi = f.read()
510
- f.close()
511
- return contract_abi
512
-
513
- def __load_env(self):
514
- if os.getenv('ETNY_CHAIN_ID') is not None:
515
- self.chain_id = int(os.getenv('ETNY_CHAIN_ID'))
516
-
517
- if os.getenv('ETNY_SMART_CONTRACT_ADDRESS') is not None:
518
- self.smart_contract_address = os.getenv('ETNY_SMART_CONTRACT_ADDRESS').rstrip()
519
-
520
- if os.getenv('ETNY_WEB3_PROVIDER') is not None:
521
- self.web3_provider = os.getenv('ETNY_WEB3_PROVIDER').rstrip()
522
-
523
- self.client_challenge = os.getenv('ETNY_CLIENT_CHALLENGE')
524
- self.order_id = int(os.getenv('ETNY_ORDER_ID'))
525
-
526
- def read_client_challenge(self):
527
- self.encrypted_tuple = crypto.encrypted_data_from_base64_json(self.client_challenge)
528
-
529
- def generate_challenge(self, length=20):
530
- lowercase_only = ''.join(
531
- random.choice(string.ascii_lowercase) for _ in range(length)
532
- )
533
- #print('random challenge:', lowercase_only)
534
- self.enclave_challenge = lowercase_only
535
- return self.enclave_challenge
536
-
537
- def build_result(self):
538
- self.result = (f'{self.version}:{self.task_code}:{self.task_result_checksum}:{self.enclave_challenge}:')
539
-
540
- def addResult(self):
541
- self.build_result()
542
- print(f'adding result to order {self.order_id}')
543
- nonce = self.w3.eth.getTransactionCount(self.publickey)
544
- unicorn_txn = self.etny.functions._addResultToOrder(
545
- self.order_id, self.result
546
- ).buildTransaction({
547
- 'gas': 1000000,
548
- 'chainId': self.chain_id,
549
- 'nonce': nonce,
550
- 'gasPrice': self.w3.toWei("1", "mwei"),
551
- })
552
-
553
- signed_txn = self.w3.eth.account.sign_transaction(unicorn_txn, private_key=self.acct.key)
554
- self.w3.eth.sendRawTransaction(signed_txn.rawTransaction)
555
- # self.transaction = unicorn_tx signed
556
- hash = self.w3.toHex(self.w3.sha3(signed_txn.rawTransaction))
557
-
558
- try:
559
- receipt = self.w3.eth.waitForTransactionReceipt(hash)
560
- except Exception as e:
561
- print(f'An error occurred while sending transaction result {self.order_id}: ', e)
562
- raise
563
-
564
- #print('transaction status: ', receipt.status)
565
- #print('transaction receipt: ', receipt)
566
- if receipt.status == 1:
567
- print("Result transaction was successful!")
568
- else:
569
- print("Result transaction was UNSUCCESSFUL!")
570
-
571
- #print("Result payload: %s" % self.result)
572
- #print("TX Raw: %s" % signed_txn.rawTransaction)
573
- #print("TX Hash: %s" % hash)
574
-
575
- def build_transaction(self):
576
- self.build_result()
577
- print(f'adding result to order {self.order_id}')
578
- nonce = self.w3.eth.getTransactionCount(self.publickey)
579
- unicorn_txn = self.etny.functions._addResultToOrder(
580
- self.order_id, self.result
581
- ).buildTransaction({
582
- 'gas': 1000000,
583
- 'chainId': self.chain_id,
584
- 'nonce': nonce,
585
- 'gasPrice': self.w3.toWei("1", "mwei"),
586
- })
587
-
588
- signed_txn = self.w3.eth.account.sign_transaction(unicorn_txn, private_key=self.acct.key)
589
- signed_tx_as_bytes = binascii.hexlify(signed_txn.rawTransaction)
590
- self.signed_tx_as_bytes = signed_tx_as_bytes
591
-
592
- def get_result_checksum(self):
593
- self.task_result_checksum = hashlib.sha256(self.task_result.encode("utf-8")).hexdigest()
594
-
595
- def save_result(self):
596
- self.__get_latest_trusted_zone_public_key()
597
- self.encrypt_file_and_push_to_swifstream(str(self.task_result), "result.txt")
598
- self.encrypt_file_and_push_to_swifstream(str(self.task_code), "result_code.txt")
599
-
600
- def save_pub_cert(self):
601
- self.swift_stream_service.create_bucket(self.etny_bucket)
602
- self.swift_stream_service.put_file_content(self.etny_bucket,
603
- "cert.pem",
604
- self.cert_file)
605
-
606
- def save_transaction(self):
607
- self.swift_stream_service.create_bucket(self.etny_bucket)
608
- transaction = io.BytesIO(self.signed_tx_as_bytes)
609
- self.swift_stream_service.put_file_content(self.etny_bucket,
610
- "transaction.txt",
611
- self.transaction_file,
612
- transaction)
613
-
614
- def execute(self):
615
- # ADJUDICATE the CAS attestation verdict recorded at startup. An
616
- # INVALID CAS quote terminates the task before the payload runs; the
617
- # result (code 40, CAS_ATTESTATION_FAULT) is saved by the normal
618
- # save_result() path and forwarded on-chain by the trustedzone --
619
- # the refundable operator-fault path.
620
- if getattr(self, 'cas_attestation_fault', None):
621
- self.task_code = '40'
622
- self.task_result = (
623
- f'CAS_ATTESTATION_FAULT: {self.cas_attestation_fault} -- the '
624
- f'operator provisioned this task through a CAS that failed '
625
- f'self-attestation; order terminated for refund.')
626
- return
627
-
628
- payload_data = self.__get_file_content_and_decrypt(self.payload)
629
- input_data = self.__get_file_content_and_decrypt(self.input)
630
- print('Validate client payload and input')
631
- self.validate_client_payload(payload_data, input_data)
632
- if app.is_valid_client_data:
633
- print('Client payload and input are valid')
634
- else:
635
- print('Client payload and input are NOT valid')
636
- return
637
-
638
- task_result = execute_task(payload_data, input_data)
639
- print('TASK RESULT (code, data):', task_result)
640
- self.task_code = str(task_result[0])
641
- self.task_result = task_result[1]
642
-
643
- def __get_file_content_and_decrypt(self, object_name):
644
- status, encrypted_base64 = self.swift_stream_service.get_file_content(self.etny_bucket, object_name)
645
- if not status:
646
- print(f'Failed to get {object_name} file')
647
- raise Exception(f'Failed to get {object_name} file')
648
-
649
- encrypted_tuple = crypto.encrypted_data_from_base64_json(encrypted_base64.encode('utf-8'))
650
- decrypted_result = crypto.decrypt(self.key_file, encrypted_tuple)
651
- return decrypted_result.decode('utf-8')
652
-
653
- def encrypt_file_and_push_to_swifstream(self, file_data, file_name):
654
- encrypted_input = crypto.encrypt_with_pub_key(self.trusted_zone_public_key, file_data.encode('utf-8'))
655
- encrypted_input_base64 = crypto.encrypted_data_to_base64_json(encrypted_input)
656
-
657
- # file name should end with secure lock '.securelock' extension
658
- file_name = file_name + '.securelock'
659
- self.swift_stream_service.create_bucket(self.etny_bucket)
660
- data = io.BytesIO(encrypted_input_base64)
661
- status, _ = self.swift_stream_service.put_file_content(self.etny_bucket,
662
- file_name,
663
- "",
664
- data)
665
- if status:
666
- print(f'File {file_name} encrypted and saved to swift stream successfully')
667
-
668
- def __get_trusted_zone_public_key(self):
669
- print('getting the public key of the trusted zone enclave')
670
- image_hash = self._metadata.image_hash
671
- if EtnySecureLock.debug:
672
- image_hash = 'v3-hash-1'
673
- self.trusted_zone_public_key = self.image_registry.caller().getTrustedZoneImageCertPublicKey(
674
- image_hash)
675
-
676
- def __get_latest_trusted_zone_public_key(self):
677
- print('getting latest public key of the trusted zone enclave')
678
- self.trusted_zone_public_key = self.image_registry.caller().getLatestTrustedZoneImageCertPublicKey(
679
- '__TRUSTED_ZONE_IMAGE__', 'v3')[1]
680
-
681
-
682
- if __name__ == '__main__':
683
- print('[SecureLock] Loading env variables..')
684
- try:
685
- if EtnySecureLock.debug:
686
- swiftStreamClient = SwiftStreamService("localhost:9000",
687
- "swiftstreamadmin",
688
- "swiftstreamadmin")
689
- else:
690
- swiftStreamClient = SwiftStreamService("etny-swift-stream:9000",
691
- "swiftstreamadmin",
692
- "swiftstreamadmin")
693
-
694
- status, env_content = swiftStreamClient.get_file_content("etny-pynithy-testnet-v3", ".env")
695
- if not status:
696
- print("Failed to get .env file")
697
- raise Exception("Failed to get .env file")
698
- EtnySecureLock.read_env_str(env_content)
699
- except:
700
- cert_file = "/app/__ENCLAVE_NAME_SECURELOCK__/cert.pem"
701
- with open(cert_file, 'r') as f:
702
- print("PUBLIC_CERT:", f.read())
703
- exit(1)
704
-
705
- if not EtnySecureLock.debug:
706
- with open("/app/__ENCLAVE_NAME_SECURELOCK__/cert.pem", 'r') as f:
707
- print("PUBLIC_CERT:", f.read())
708
-
709
- print('Initializing..')
710
- app = EtnySecureLock(swiftStreamClient)
711
- print('Validate client payload and input')
712
- app.wait_for_payload_and_input()
713
-
714
- if app.is_valid_client_data:
715
- print('Executing client code..')
716
- app.execute()
717
- else:
718
- print('Failed client payload and input validation')
719
- # print('Generating task result checksum..')
720
- # app.get_result_checksum()
721
- # print('Building transaction..')
722
- # app.build_transaction()
723
- # print('Saving transaction...')
724
- # app.save_transaction()
725
- # print('Saving the result..')
726
- app.save_result()
727
- print('Finished the execution')
1
+ #!/usr/bin/python
2
+
3
+ import binascii
4
+ import hashlib
5
+ import hmac
6
+ import os
7
+ import io
8
+ import time
9
+ import datetime
10
+ from datetime import timedelta
11
+ import logging
12
+ import struct
13
+ import ctypes
14
+ import re
15
+ import string
16
+ import random
17
+ import base64 # CAS self-attestation quote decoding
18
+ import json
19
+ import secrets # CAS attestation challenge nonce
20
+ import urllib.request # enclave-initiated CAS attestation endpoint
21
+ from cryptography import x509
22
+ from cryptography.x509.oid import NameOID
23
+ from cryptography.hazmat.primitives import serialization, hashes
24
+ from cryptography.hazmat.primitives.asymmetric import ec
25
+ from cryptography.hazmat.backends import default_backend
26
+ from cryptography.x509.extensions import UnrecognizedExtension
27
+ from etny_crypto import etny_crypto as crypto
28
+ from web3 import Web3
29
+ from web3.middleware import ExtraDataToPOAMiddleware
30
+ from etny_exec import execute_task_v3, TaskStatus
31
+ from models import *
32
+ from swift_stream_service import SwiftStreamService
33
+ # Static import so PyInstaller's dependency analysis bundles it into binary-fs
34
+ # (a lazy import inside a function is invisible to the freeze and would fail
35
+ # only at runtime, inside the enclave, where it cannot be diagnosed).
36
+ import esr_wallet
37
+ # Suppress pkg_resources warning if using web3
38
+ import warnings
39
+ warnings.filterwarnings("ignore", category=UserWarning, module="web3")
40
+ # Setup logging
41
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
42
+ class EtnySecureLock:
43
+ def __init__(self, cert_pem: str, private_key_pem: str, swiftStreamClient: SwiftStreamService):
44
+ self.swift_stream_service = swiftStreamClient
45
+ self.version = 'v3'
46
+ logging.info(f'ETNY Pynithy [{self.version}]')
47
+ self.__set_initializers()
48
+ self.private_key_pem = private_key_pem
49
+ self.cert_pem = cert_pem
50
+ self.save_pub_cert()
51
+ self.__load_env()
52
+ self.read_contract_abi()
53
+ self.init_web3()
54
+ self.verify_cas_attestation()
55
+ self.print_env()
56
+ def __set_initializers(self):
57
+ self.is_valid_client_data = False
58
+ self.payload = 'payload.securelock'
59
+ self.input = 'input.securelock'
60
+ self.result = 'result.trustedzone'
61
+ self.result_code = 'result_code.trustedzone'
62
+ self.result_file = '/app/result.txt'
63
+ self.transaction_file = '/app/transaction.txt'
64
+ # Chain identity, substituted at BUILD by pyfreeze.sh from the selected
65
+ # network. These were bloxberg literals, which the sed calls could not
66
+ # replace, so every enclave shipped pointing at bloxberg's protocol and
67
+ # image registry: on any other network the registry lookup reverted
68
+ # ("Could not transact with/call contract function").
69
+ self.smart_contract_address = '__SMART_CONTRACT_ADDRESS__'
70
+ self.image_registry_address = '__IMAGE_REGISTRY_ADDRESS__'
71
+ # ethernity-cas ValidatorRegistry, substituted at BUILD by pyfreeze.sh
72
+ # (never CAS- or session-supplied: a rogue CAS must not choose the
73
+ # registry that judges it). Empty = skip CAS self-attestation.
74
+ self.validator_registry_address = '__VALIDATOR_REGISTRY_ADDRESS__'
75
+ self.chain_id = __CHAIN_ID__
76
+ self.web3_provider = '__RPC_URL__'
77
+ self.etny_bucket = "__BUCKET_NAME__"
78
+ self.trustedzone_public_key = ''
79
+ self.trustedzone_public_key_pem = ''
80
+ self.trustedzone_image_hash = ''
81
+ self.trustedzone_composer_hash = ''
82
+ @staticmethod
83
+ def generate_cert_from_mrenclave():
84
+ logging.info("Generating self-signed certificate based on MR_ENCLAVE (no CAS available)")
85
+ # Load the SGX library using ctypes
86
+ sgx_lib_path = '/etny-securelock/get_sgx_report.so'
87
+ if not os.path.exists(sgx_lib_path):
88
+ raise FileNotFoundError(f"SGX library not found at {sgx_lib_path}")
89
+ sgx_lib = ctypes.CDLL(sgx_lib_path)
90
+ sgx_lib.get_mr_enclave.restype = ctypes.c_char_p
91
+ sgx_lib.get_mr_signer.restype = ctypes.c_char_p
92
+ sgx_lib.derive_identity_scalar.restype = ctypes.c_char_p
93
+ sgx_lib.derive_identity_scalar.argtypes = [ctypes.c_int]
94
+
95
+ # Get MR_ENCLAVE (for the cert extension / log) from the module.
96
+ mr_enclave_ptr = sgx_lib.get_mr_enclave()
97
+ if not mr_enclave_ptr:
98
+ raise ValueError("Failed to retrieve MR_ENCLAVE from SGX")
99
+ mr_enclave_hex = mr_enclave_ptr.decode('utf-8')
100
+ logging.info(f"MR_ENCLAVE: {mr_enclave_hex}")
101
+
102
+ # Identity-key derivation now lives ENTIRELY in the compiled module
103
+ # (get_sgx_report.so). derive_identity_scalar(role) returns the 96-hex
104
+ # secp384r1 scalar; role 0 = SecureLock. The algorithm (HKDF-SHA512,
105
+ # domain tags, 4096-round stretch, rejection sampling) is not present in
106
+ # this interpreted source -- it is in the .so only, so reading the
107
+ # Python reveals nothing about how the key is derived. The .c source of
108
+ # truth lives in the etny-pynithy / etny-nodenithy repos; the SDK ships
109
+ # only the built .so.
110
+ ROLE_SECURELOCK = 0
111
+ scalar_ptr = sgx_lib.derive_identity_scalar(ROLE_SECURELOCK)
112
+ if not scalar_ptr:
113
+ raise ValueError("derive_identity_scalar failed in the SGX module")
114
+ private_value = int(scalar_ptr.decode('utf-8'), 16)
115
+
116
+ curve = ec.SECP384R1()
117
+ private_key = ec.derive_private_key(private_value, curve, default_backend())
118
+ public_key = private_key.public_key()
119
+
120
+ # Custom extension for MR_ENCLAVE
121
+ custom_oid = x509.ObjectIdentifier("1.3.6.1.4.1.99999.1") # Arbitrary custom OID
122
+ mr_enclave_value = mr_enclave_hex.encode('utf-8')
123
+
124
+ # Build certificate
125
+ one_day = timedelta(days=1)
126
+ builder = x509.CertificateBuilder()
127
+ builder = builder.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, u"SecureLock Enclave")]))
128
+ builder = builder.issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, u"SecureLock Enclave")]))
129
+ builder = builder.not_valid_before(datetime.datetime.today() - one_day)
130
+ builder = builder.not_valid_after(datetime.datetime.today() + (one_day * 365))
131
+ builder = builder.serial_number(x509.random_serial_number())
132
+ builder = builder.public_key(public_key)
133
+ builder = builder.add_extension(
134
+ UnrecognizedExtension(custom_oid, mr_enclave_value),
135
+ critical=False
136
+ )
137
+ certificate = builder.sign(
138
+ private_key=private_key,
139
+ algorithm=hashes.SHA256(),
140
+ backend=default_backend()
141
+ )
142
+ # Store in memory instead of writing to disk
143
+ private_key_pem = private_key.private_bytes(
144
+ encoding=serialization.Encoding.PEM,
145
+ format=serialization.PrivateFormat.PKCS8,
146
+ encryption_algorithm=serialization.NoEncryption()
147
+ )
148
+ cert_pem = certificate.public_bytes(encoding=serialization.Encoding.PEM)
149
+ logging.info("Generated key and cert in memory based on MR_ENCLAVE")
150
+ return private_key_pem, cert_pem
151
+
152
+ @staticmethod
153
+ def read_env_str(env_str: str):
154
+ with io.StringIO(env_str) as f:
155
+ for line in f:
156
+ if '=' not in line:
157
+ continue
158
+ key, value = line.split('=', 1)
159
+ os.environ[key.strip()] = re.sub(r'\n', '', value.strip())
160
+ def validate_client_payload(self):
161
+ payload_data = self.__get_file_content_and_decrypt(self.payload)
162
+ input_data = self.__get_file_content_and_decrypt(self.input)
163
+ max_retries = 5
164
+ for attempt in range(max_retries):
165
+ try:
166
+ self.get_do_request_metadata()
167
+ break
168
+ except Exception as e:
169
+ if attempt == max_retries - 1:
170
+ self.task_code = TaskStatus.SYSTEM_ERROR
171
+ self.task_result = f'Failed to fetch metadata after {max_retries} attempts: {str(e)}'
172
+ self.is_valid_client_data = False
173
+ return
174
+ # Assume is_valid_client_data is False by default; we'll set to True only if all checks pass.
175
+
176
+ def validate_checksum(metadata_obj, data, owner, error_code, error_msg):
177
+ """Validate one file's integrity against its on-chain checksum (v4).
178
+
179
+ The DO-request is submitted on-chain by the owner, so the metadata
180
+ checksum is already authenticated by the transaction sender; we only
181
+ need to confirm the file content hashes to that recorded checksum.
182
+ Kept identical to the trustedzone enclave so both agree.
183
+ """
184
+ checksum = self.compute_sha256_checksum(data)
185
+ tx_checksum = metadata_obj.checksum.lower().removeprefix('0x')
186
+
187
+ if tx_checksum != checksum:
188
+ self.task_code = error_code
189
+ self.task_result = error_msg
190
+ return False
191
+ return True
192
+
193
+ owner = self.order_metadata.do_owner.lower()
194
+
195
+ # Payload is always required and always carries a checksum.
196
+ if self._metadata.payload_metadata_obj.has_checksum:
197
+ try:
198
+ payload_valid = validate_checksum(
199
+ self._metadata.payload_metadata_obj, payload_data, owner,
200
+ TaskStatus.PAYLOAD_CHECKSUM_ERROR, 'PAYLOAD CHECKSUM DOESN\'T MATCH')
201
+ except Exception:
202
+ self.task_code = TaskStatus.PAYLOAD_CHECKSUM_ERROR
203
+ self.task_result = 'PAYLOAD CHECKSUM CHECK ERROR'
204
+ payload_valid = False
205
+ else:
206
+ self.task_code = TaskStatus.PAYLOAD_CHECKSUM_ERROR
207
+ self.task_result = 'PAYLOAD CHECKSUM DOESN\'T EXIST'
208
+ payload_valid = False
209
+
210
+ # Empty input carries no checksum -> nothing to validate.
211
+ if not self._metadata.input_metadata_obj.has_checksum:
212
+ input_valid = True
213
+ else:
214
+ try:
215
+ input_valid = validate_checksum(
216
+ self._metadata.input_metadata_obj, input_data, owner,
217
+ TaskStatus.INPUT_CHECKSUM_ERROR, 'INPUT CHECKSUM DOESN\'T MATCH')
218
+ except Exception:
219
+ self.task_code = TaskStatus.INPUT_CHECKSUM_ERROR
220
+ self.task_result = 'INPUT CHECKSUM CHECK ERROR'
221
+ input_valid = False
222
+
223
+ if payload_valid and input_valid:
224
+ self.is_valid_client_data = True
225
+ def wait_for_payload_and_input(self):
226
+ logging.info('Waiting for payload and input')
227
+ self.wait_for_trustedzone(self.etny_bucket, self.payload)
228
+ self.wait_for_trustedzone(self.etny_bucket, self.input)
229
+
230
+ def wait_for_trustedzone(self, bucket_name: str, object_name: str, timeout: int = 3600):
231
+ i = 0
232
+ logging.info(f'Checking if object {object_name} exists in bucket {bucket_name}')
233
+ while i < timeout:
234
+ time.sleep(1)
235
+ i += 1
236
+ status, _ = self.swift_stream_service.is_object_in_bucket(bucket_name, object_name)
237
+ if status:
238
+ break
239
+ logging.info('trusted zone finished the upload')
240
+ def get_do_request_metadata(self):
241
+ order = Order(self.etny.caller()._getOrder(self.order_id), self.order_id)
242
+ self._metadata = DOReqMetadata(self.etny.caller()._getDORequestMetadata(order.do_req), order.do_req)
243
+ self.order_metadata = order
244
+ def compute_sha256_checksum(self, file_data: str) -> str:
245
+ if isinstance(file_data, str):
246
+ file_data = file_data.encode('utf-8')
247
+ return hashlib.sha256(file_data).hexdigest()
248
+
249
+ def print_env(self):
250
+ logging.info(f'chain id: {self.chain_id}')
251
+ logging.info(f'smart contract address: {self.smart_contract_address}')
252
+ logging.info(f'web3 provider: {self.web3_provider}')
253
+ logging.info(f'order id: {self.order_id}')
254
+ def init_web3(self):
255
+ self.w3 = Web3(Web3.HTTPProvider(self.web3_provider))
256
+ self.w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
257
+ self.etny = self.w3.eth.contract(address=self.smart_contract_address,
258
+ abi=self.contract_abi)
259
+ self.image_registry = self.w3.eth.contract(address=self.image_registry_address,
260
+ abi=self.image_registry_abi)
261
+
262
+ def _cas_endpoints_from_chain(self, cas_address):
263
+ """Endpoints for a CAS, read FROM CHAIN -- never from the environment.
264
+
265
+ The node operator controls the host and everything the enclave is
266
+ handed, so the address to dial must come from the ValidatorRegistry
267
+ itself. /onion3 is preferred, then the rest, matching the node-side
268
+ resolver. Returns REST base URLs.
269
+ """
270
+ # Endpoints live in the CasKeyStore the registry names via keyStore();
271
+ # the registry's own forwarders were removed when it reached the
272
+ # EIP-170 size limit.
273
+ registry_abi = [
274
+ {"name": "keyStore", "type": "function", "stateMutability": "view",
275
+ "inputs": [], "outputs": [{"type": "address"}]},
276
+ ]
277
+ store_abi = [
278
+ {"name": "multiaddrsOf", "type": "function", "stateMutability": "view",
279
+ "inputs": [{"type": "address"}], "outputs": [{"type": "string[]"}]},
280
+ ]
281
+ try:
282
+ vr = self.w3.eth.contract(
283
+ address=self.w3.to_checksum_address(self.validator_registry_address),
284
+ abi=registry_abi)
285
+ store = self.w3.eth.contract(address=vr.caller().keyStore(), abi=store_abi)
286
+ mas = store.caller().multiaddrsOf(self.w3.to_checksum_address(cas_address))
287
+ except Exception as e:
288
+ logging.warning(f'CAS attestation: reading the CAS endpoints from chain failed: {e}')
289
+ return []
290
+ onion, rest = [], []
291
+ for ma in mas:
292
+ parts = [p for p in str(ma).split('/') if p]
293
+ if len(parts) != 4 or parts[2] != 'tcp':
294
+ continue
295
+ kind, host, port = parts[0], parts[1], parts[3]
296
+ try:
297
+ port = int(port)
298
+ except ValueError:
299
+ continue
300
+ # REST pairing convention: a CAS publishes its enclave port only,
301
+ # and its REST port sits at the same offset from the in-container
302
+ # pair 18765/8081 (19768 -> 9084).
303
+ rest_port = 8081 + (port - 18765)
304
+ if not 1 <= rest_port <= 65535:
305
+ continue
306
+ if kind == 'onion3':
307
+ host = host if host.endswith('.onion') else host + '.onion'
308
+ onion.append(f'http://{host}:{rest_port}')
309
+ elif kind in ('dns4', 'dns6', 'dns', 'ip4', 'ip6'):
310
+ rest.append(f'http://{host}:{rest_port}')
311
+ return onion + rest
312
+
313
+ def challenge_cas(self, cas_address, registered_mrenclave, registered_cert):
314
+ """
315
+ ENCLAVE-INITIATED CAS attestation: prove the CAS is live NOW.
316
+
317
+ The CAS is remote and the operator sits on the path, so the injected
318
+ environment alone cannot prove liveness. The CAS minted a RANDOM
319
+ session id (ECAS_ATTEST_SESSION) it holds OPEN; we resolve its
320
+ endpoint FROM CHAIN, present that id with a nonce WE choose, and it
321
+ returns a FRESH quote binding
322
+ sha256(session || nonce || cas_addr || cert_hash). A captured
323
+ environment stops working when its session closes; a captured
324
+ RESPONSE cannot match a different nonce. Returns None on success,
325
+ else the failure reason.
326
+ """
327
+ session_id = os.environ.get('ECAS_ATTEST_SESSION', '')
328
+ if not session_id:
329
+ return 'no ECAS_ATTEST_SESSION in the environment (CAS cannot be challenged)'
330
+ nonce = secrets.token_bytes(32)
331
+ # Echo back the nonce the CAS injected, so the SAME fresh quote also
332
+ # re-attests the environment we were handed (report_data[52:64]). The
333
+ # CAS recomputes the injected commitment from its own address and cert
334
+ # -- we supply only the nonce -- so this cannot be steered.
335
+ env_nonce_hex = os.environ.get('ECAS_ENCLAVE_NONCE', '')
336
+ payload = json.dumps({'session': session_id, 'nonce': nonce.hex(),
337
+ 'env_nonce': env_nonce_hex}).encode()
338
+
339
+
340
+
341
+ endpoints = self._cas_endpoints_from_chain(cas_address)
342
+ if not endpoints:
343
+ return 'the CAS publishes no dialable endpoint on chain'
344
+ last = 'no endpoint answered'
345
+ for base in endpoints:
346
+ url = f'{base}/attest/session'
347
+ try:
348
+ req = urllib.request.Request(
349
+ url, data=payload, headers={'Content-Type': 'application/json'})
350
+ with urllib.request.urlopen(req, timeout=20) as r:
351
+ doc = json.loads(r.read().decode('utf-8', 'replace'))
352
+ except Exception as e:
353
+ last = f'{url}: {e}'
354
+ continue
355
+ try:
356
+ quote = bytes.fromhex(doc.get('quote', ''))
357
+ except ValueError:
358
+ return f'{url}: challenge quote is not hex'
359
+ if len(quote) < 48 + 384:
360
+ return f'{url}: challenge quote too short ({len(quote)} bytes)'
361
+ rd = quote[368:432]
362
+ got_addr = '0x' + rd[32:52].hex()
363
+ if got_addr.lower() != cas_address.lower():
364
+ return (f'challenge quote names {got_addr}, not the CAS '
365
+ f'{cas_address} we verified on chain')
366
+ expect = hashlib.sha256(
367
+ session_id.encode() + nonce
368
+ + bytes.fromhex(cas_address[2:]) + bytes.fromhex(registered_cert)
369
+ ).digest()
370
+ if rd[0:32] != expect:
371
+ return ('challenge commitment mismatch: the CAS did not sign '
372
+ 'our session+nonce (stale or replayed response)')
373
+ # ENVIRONMENT RE-ATTESTATION. When we sent env_nonce, the fresh
374
+ # quote must also carry the head of the commitment that went into
375
+ # ECAS_CAS_QUOTE. This is what ties the two quotes together: a live
376
+ # CAS confirming, under a NEW signature, that the environment we
377
+ # hold is the one it sent. Without it the challenge proved only
378
+ # that some CAS was alive.
379
+ if env_nonce_hex:
380
+ env_expect = hashlib.sha256(
381
+ session_id.encode() + bytes.fromhex(env_nonce_hex)
382
+ + bytes.fromhex(cas_address[2:])
383
+ + bytes.fromhex(registered_cert)).digest()
384
+ if rd[52:64] != env_expect[0:12]:
385
+ return ('the CAS did not confirm the environment it sent: '
386
+ f'fresh quote carries {rd[52:64].hex()}, expected '
387
+ f'{env_expect[0:12].hex()}')
388
+ logging.info('CAS re-attested the injected environment under a '
389
+ 'fresh signature')
390
+ qmr = quote[112:144].hex()
391
+ if qmr.lower() != registered_mrenclave.lower():
392
+ return (f'challenge quote MRENCLAVE {qmr} != registered '
393
+ f'{registered_mrenclave}')
394
+ logging.info(
395
+ f'CAS CHALLENGE PASSED: {base} signed our session+nonce with a '
396
+ f'fresh quote (MRENCLAVE {qmr[:16]}...)')
397
+ return None
398
+ return f'CAS challenge failed: {last}'
399
+
400
+ def verify_cas_attestation(self):
401
+ """
402
+ Verify the CAS that provisioned this enclave (the OUTBOUND half of
403
+ trust; the CAS attesting us is the inbound half).
404
+
405
+ The CAS injects ECAS_CAS_QUOTE into the session environment -- a DCAP
406
+ quote whose report_data binds its EGETKEY validator address (bytes
407
+ 0:20) and the SHA-256 of its certificate (bytes 20:52). We check it
408
+ against the ValidatorRegistry baked into this build: the address must
409
+ be an ACTIVE validator, the quote MRENCLAVE must equal that
410
+ validator's registered measurement, and the bound cert hash must
411
+ equal the registered certHash. Evicting a CAS on-chain therefore
412
+ revokes it HERE on the next task, without redeploying enclaves.
413
+
414
+ STRICT: a missing quote, an invalid one, and an unreachable
415
+ registry (after retries) are all the same terminal fault (refund);
416
+ there is no softening knob -- the session env comes from the CAS
417
+ itself. Debug-signed CAS quotes are tolerated on testnet builds
418
+ only. The quote's ECDSA/PCK signature
419
+ chain is NOT yet verified in-enclave; what this binds is
420
+ quote-contents <-> on-chain record, which is the revocation path.
421
+ """
422
+ registry = self.validator_registry_address
423
+ self.cas_attestation_fault = None
424
+
425
+ def fault(reason):
426
+ # TERMINAL: registry present -> a CAS quote is MANDATORY, so a
427
+ # MISSING quote is as damning as a wrong one. The operator provisioned this task through an
428
+ # impostor CAS. Recorded here and adjudicated in execute() before
429
+ # the payload runs -- the task then terminates with
430
+ # CAS_ATTESTATION_FAULT and the order is refunded.
431
+ logging.error(f'CAS ATTESTATION INVALID: {reason}')
432
+ self.cas_attestation_fault = reason
433
+
434
+ if not registry or registry.startswith('__'):
435
+ logging.info('CAS attestation: no ValidatorRegistry for this network; skipping')
436
+ return
437
+ quote_b64 = os.environ.get('ECAS_CAS_QUOTE', '')
438
+ if not quote_b64:
439
+ return fault('no ECAS_CAS_QUOTE in the session environment (unattested CAS)')
440
+ try:
441
+ quote = base64.b64decode(quote_b64)
442
+ except Exception as e:
443
+ return fault(f'ECAS_CAS_QUOTE is not valid base64: {e}')
444
+ # DCAP quote layout: 48-byte header, then the report body --
445
+ # ATTRIBUTES at body+48 (16), MRENCLAVE at body+64 (32),
446
+ # report_data at body+320 (64).
447
+ if len(quote) < 48 + 384:
448
+ return fault(f'quote too short ({len(quote)} bytes)')
449
+ attributes = quote[96:112]
450
+ quote_mrenclave = quote[112:144].hex()
451
+ report_data = quote[368:432]
452
+ # TWO report_data layouts, distinguished by which one names a
453
+ # REGISTERED validator (checked below):
454
+ # fresh (per-session): [0:32] freshness commitment, [32:52] address
455
+ # legacy (boot quote): [0:20] address, [20:52] cert hash
456
+ # The fresh layout is what a current CAS injects; legacy remains only
457
+ # for a CAS that could not read this enclave's nonce. Reading the wrong
458
+ # one would parse commitment bytes as an address and fail every task,
459
+ # so both are resolved and the registry decides which is real.
460
+ cas_candidates = [
461
+ (self.w3.to_checksum_address('0x' + report_data[32:52].hex()), None),
462
+ (self.w3.to_checksum_address('0x' + report_data[0:20].hex()),
463
+ report_data[20:52].hex()),
464
+ ]
465
+
466
+ vr_abi = [
467
+ {"name": "isValidator", "type": "function", "stateMutability": "view",
468
+ "inputs": [{"type": "address"}], "outputs": [{"type": "bool"}]},
469
+ # The registry's Validator struct: the measurement is `bytes` at
470
+ # its platform width (32 for SGX MRENCLAVE), not bytes32.
471
+ {"name": "validators", "type": "function", "stateMutability": "view",
472
+ "inputs": [{"type": "address"}],
473
+ "outputs": [{"name": "measurement", "type": "bytes"},
474
+ {"name": "certHash", "type": "bytes32"},
475
+ {"name": "active", "type": "bool"},
476
+ {"name": "admittedBlock", "type": "uint64"},
477
+ {"name": "lastVoteBlock", "type": "uint64"},
478
+ {"name": "teeType", "type": "uint16"},
479
+ {"name": "operator", "type": "address"}]},
480
+ ]
481
+ last_err = None
482
+ cas_address, bound_cert, active, record = None, None, False, None
483
+ for _attempt in range(3):
484
+ try:
485
+ vr = self.w3.eth.contract(address=self.w3.to_checksum_address(registry),
486
+ abi=vr_abi)
487
+ # Whichever layout names an ACTIVE validator is the real one.
488
+ for cand_addr, cand_cert in cas_candidates:
489
+ if vr.caller().isValidator(cand_addr):
490
+ cas_address, bound_cert = cand_addr, cand_cert
491
+ active = True
492
+ record = vr.caller().validators(cand_addr)
493
+ break
494
+ if not active:
495
+ # Both layouts resolved to non-validators: report the
496
+ # fresh-layout address, which is what a current CAS sends.
497
+ cas_address, bound_cert = cas_candidates[0]
498
+ last_err = None
499
+ break
500
+ except Exception as e:
501
+ last_err = e
502
+ time.sleep(5)
503
+ if last_err is not None:
504
+ # The enclave already depends on this chain for its order
505
+ # metadata, so an unreachable registry is not a new fragility --
506
+ # and treating it softly would let an operator bypass the check
507
+ # by firewalling the RPC.
508
+ return fault(f'ValidatorRegistry {registry} unreachable after 3 attempts: {last_err}')
509
+ if not active:
510
+ return fault(f'CAS {cas_address} is NOT an active validator')
511
+ registered_mr = record[0].hex()
512
+ if registered_mr != quote_mrenclave:
513
+ return fault(f'CAS quote MRENCLAVE {quote_mrenclave} != registered {registered_mr}')
514
+ registered_cert = record[1].hex()
515
+ if bound_cert is not None:
516
+ # Legacy layout: the cert hash is a readable field, compare it.
517
+ if registered_cert != '00' * 32 and registered_cert != bound_cert:
518
+ return fault(f'CAS quote cert hash {bound_cert} != registered {registered_cert}')
519
+ else:
520
+ # FRESH layout: the cert hash is not a readable field -- it is
521
+ # folded into report_data[0:32] together with the attestation
522
+ # SESSION ID and this enclave's own nonce. Recomputing that
523
+ # commitment is what binds the injected ENVIRONMENT AS ONE UNIT:
524
+ # ECAS_ATTEST_SESSION, ECAS_ENCLAVE_NONCE and ECAS_CAS_QUOTE only
525
+ # agree if the CAS minted all three together for THIS session. An
526
+ # operator who strips the session id, swaps in another enclave's
527
+ # id, or pairs a captured quote with a fresh session breaks the
528
+ # hash -- and cannot repair it, because the commitment is inside
529
+ # an SGX signature.
530
+ #
531
+ # The nonce is echoed by the CAS because SCONE gives the enclave
532
+ # no way to read its own attestation report_data. Echoing costs
533
+ # nothing: a tampered nonce simply fails to reproduce [0:32].
534
+ if registered_cert == '00' * 32:
535
+ # Without a registered cert there is nothing to recompute
536
+ # against, so the binding cannot be proven. Tolerating that
537
+ # would hand every CAS an opt-out: leave certHash unset and
538
+ # the strongest check silently disappears.
539
+ return fault(f'CAS {cas_address} has no certHash registered; '
540
+ 'the fresh quote binding cannot be verified')
541
+ session_id = os.environ.get('ECAS_ATTEST_SESSION', '')
542
+ nonce_hex = os.environ.get('ECAS_ENCLAVE_NONCE', '')
543
+ if not session_id:
544
+ return fault('fresh CAS quote but no ECAS_ATTEST_SESSION '
545
+ '(environment was tampered with)')
546
+ if not nonce_hex:
547
+ return fault('fresh CAS quote but no ECAS_ENCLAVE_NONCE '
548
+ '(environment was tampered with)')
549
+ try:
550
+ nonce = bytes.fromhex(nonce_hex)
551
+ except ValueError as e:
552
+ return fault(f'ECAS_ENCLAVE_NONCE is not hex: {e}')
553
+ expected = hashlib.sha256(
554
+ session_id.encode()
555
+ + nonce
556
+ + bytes.fromhex(cas_address[2:])
557
+ + bytes.fromhex(registered_cert)).digest()
558
+ if expected != report_data[0:32]:
559
+ return fault(
560
+ 'CAS quote does not commit to the injected environment '
561
+ f'(session {session_id[:16]}..., expected '
562
+ f'{expected.hex()[:16]}..., quote carries '
563
+ f'{report_data[0:32].hex()[:16]}...)')
564
+ logging.info('CAS environment binding OK: quote commits to '
565
+ f'attestation session {session_id[:16]}...')
566
+ # SGX ATTRIBUTES.FLAGS bit 1 = DEBUG. A debug CAS protects nothing;
567
+ # testnet builds tolerate it (the whole testnet chain is debug).
568
+ if (attributes[0] & 0x02) and '__NETWORK_TYPE__' != 'testnet':
569
+ return fault('CAS enclave runs in SGX DEBUG mode')
570
+ logging.info(f'CAS ATTESTATION VERIFIED: validator {cas_address}, '
571
+ f'MRENCLAVE {quote_mrenclave[:16]}..., certificate bound on-chain')
572
+
573
+ # LIVENESS. Everything above proves the injected quote matches an
574
+ # on-chain record -- but the environment was handed to us by the node.
575
+ # Challenge the CAS ourselves over an endpoint resolved FROM CHAIN,
576
+ # with a nonce we pick, so nothing replayed can pass. Same terminal
577
+ # fault (refund) as any other CAS-attestation failure.
578
+ challenge_error = self.challenge_cas(
579
+ cas_address, registered_mr, registered_cert)
580
+ if challenge_error is not None:
581
+ return fault(challenge_error)
582
+
583
+ def read_contract_abi(self):
584
+ self.contract_abi = self.__read_contract_abi('abi/etnyImplementationV2.abi')
585
+ self.image_registry_abi = self.__read_contract_abi('abi/image_registry.abi')
586
+ def __read_contract_abi(self, contract_name: str) -> str:
587
+ path = os.path.join(os.path.dirname(os.path.realpath(__file__)), contract_name)
588
+ with open(path, 'r') as f:
589
+ return f.read()
590
+ def __load_env(self):
591
+ if os.getenv('ETNY_CHAIN_ID'):
592
+ self.chain_id = int(os.getenv('ETNY_CHAIN_ID'))
593
+ if os.getenv('ETNY_SMART_CONTRACT_ADDRESS'):
594
+ self.smart_contract_address = os.getenv('ETNY_SMART_CONTRACT_ADDRESS').rstrip()
595
+ if os.getenv('ETNY_WEB3_PROVIDER'):
596
+ self.web3_provider = os.getenv('ETNY_WEB3_PROVIDER').rstrip()
597
+ if os.getenv('IMAGE_REGISTRY_ADDRESS'):
598
+ self.image_registry_address = os.getenv('IMAGE_REGISTRY_ADDRESS').rstrip()
599
+ self.order_id = int(os.getenv('ETNY_ORDER_ID'))
600
+
601
+ def save_result(self):
602
+ # Signed over the task binding when the trustedzone supplied one
603
+ # ("<challenge>:<order_id>:" ahead of the data), so each signature
604
+ # attests this order and this run's challenge; the data itself is
605
+ # pushed unchanged. Without a binding (older trustedzone) the bare
606
+ # data is signed, which that trustedzone verifies.
607
+ binding = getattr(self, "task_binding", None)
608
+ prefix = f"{binding['challenge']}:{binding['order_id']}:" if binding else ""
609
+ self.encrypt_file_and_push_to_swifstream(str(self.task_result), self.result, signed_prefix=prefix)
610
+ self.encrypt_file_and_push_to_swifstream(str(self.task_code), self.result_code, signed_prefix=prefix)
611
+ def save_pub_cert(self):
612
+ self.swift_stream_service.create_bucket(self.etny_bucket)
613
+ if self.cert_pem:
614
+ data = io.BytesIO(self.cert_pem)
615
+ self.swift_stream_service.put_file_content(self.etny_bucket,
616
+ "cert.pem",
617
+ "",
618
+ data)
619
+ def save_transaction(self):
620
+ self.swift_stream_service.create_bucket(self.etny_bucket)
621
+ transaction = io.BytesIO(self.signed_tx_as_bytes)
622
+ self.swift_stream_service.put_file_content(self.etny_bucket,
623
+ "transaction.txt",
624
+ self.transaction_file,
625
+ transaction)
626
+ def execute(self):
627
+ # ADJUDICATE the CAS attestation verdict recorded at startup. An
628
+ # INVALID CAS quote terminates the task before the payload runs; the
629
+ # failure reporter writes the CAS_ATTESTATION_FAULT result the
630
+ # trustedzone forwards on-chain -- the refundable operator-fault path.
631
+ if getattr(self, 'cas_attestation_fault', None):
632
+ self.task_code = TaskStatus.CAS_ATTESTATION_FAULT
633
+ self.task_result = (
634
+ f'CAS_ATTESTATION_FAULT: {self.cas_attestation_fault} -- the '
635
+ f'operator provisioned this task through a CAS that failed '
636
+ f'self-attestation; order terminated for refund.')
637
+ raise ValueError(self.task_result)
638
+ self.payload_data = self.__get_file_content_and_decrypt(self.payload)
639
+ self.input_data = self.__get_file_content_and_decrypt(self.input)
640
+ self.payload_signature = self.__get_file_content_and_decrypt(self.payload + ".sig")
641
+ self.input_signature = self.__get_file_content_and_decrypt(self.input + ".sig")
642
+
643
+ self.validate_trustedzone_data()
644
+ self.configure_state_registry()
645
+ # Interactive session: the trustedzone stages session.config when the
646
+ # request asked for one; the executor keeps the payload context alive,
647
+ # streams inputs/outputs, and finalizes with a SUCCESS summary when
648
+ # the running period completes (see etny_session_exec).
649
+ status, _ = self.swift_stream_service.is_object_in_bucket(
650
+ self.etny_bucket, 'session.config.securelock')
651
+ if status:
652
+ from etny_session_exec import run_session
653
+ logging.info('Interactive session mode')
654
+ task_result = run_session(self)
655
+ else:
656
+ task_result = execute_task_v3(self.payload_data, self.input_data)
657
+
658
+ self.task_code = str(task_result[0])
659
+ self.task_result = task_result[1]
660
+
661
+ # Post-payload: re-assert the attested caller across the ESR ledger from
662
+ # THIS securelock's private copy (self.task_caller), captured before the
663
+ # payload ran and never handed to the payload's exec globals. Corrects
664
+ # any callerUsed the payload may have forged so the trustedzone reads the
665
+ # truth, and -- if a forgery is detected -- fails the task locally too.
666
+ # Defence in depth; the trustedzone (separate enclave) and the caller
667
+ # bound into each commit's CID remain the authoritative layers. The
668
+ # re-stamped ledger and the task binding are then attested inside the
669
+ # result envelope save_result signs, so the trustedzone reads them
670
+ # from the signature rather than from the node's storage.
671
+ attestation = None
672
+ try:
673
+ import ecld_state
674
+ if ecld_state.restamp_ledger_caller(getattr(self, "task_caller", None)):
675
+ logging.error(
676
+ "ESR ledger caller was tampered with by the payload -- "
677
+ "failing the task (SECURITY_VIOLATION).")
678
+ self.task_code = str(TaskStatus.SECURITY_VIOLATION)
679
+ self.task_result = (
680
+ "SECURITY_VIOLATION: the task attempted to forge the state "
681
+ "commit caller. The attested submitter was re-asserted and "
682
+ "the task was terminated.")
683
+ attestation = ecld_state.ledger_attestation()
684
+ except Exception as e:
685
+ logging.error(f"ESR ledger re-stamp skipped: {e}")
686
+ self.task_result = self._attach_task_attestation(
687
+ self.task_result, attestation, getattr(self, "task_binding", None))
688
+
689
+ @staticmethod
690
+ def _attach_task_attestation(task_result, attestation, binding):
691
+ """Attest this securelock's view of the task inside the result envelope
692
+ the identity key signs (save_result). esr.wallet is the enclave's ESR
693
+ address and esr.commits the signed authorization records it staged
694
+ (the same objects staged to esr.authorizations.json), taken AFTER
695
+ restamp_ledger_caller so they carry the attested caller; the payload's
696
+ own entries are kept. order_id and challenge are the task binding the
697
+ trustedzone forwarded, so a client can check the decrypted result
698
+ against the on-chain result record. Only an ecld envelope can carry
699
+ them; any other result (legacy plain text, or the SECURITY_VIOLATION
700
+ text) is returned unchanged, as is an envelope when there is nothing
701
+ to attest.
702
+ """
703
+ has_ledger = bool(attestation and attestation.get("wallet"))
704
+ if not has_ledger and not binding:
705
+ return task_result
706
+ try:
707
+ envelope = json.loads(str(task_result))
708
+ except Exception:
709
+ return task_result
710
+ if not isinstance(envelope, dict) or envelope.get("ecld") != 1:
711
+ return task_result
712
+ if has_ledger:
713
+ esr = envelope.get("esr")
714
+ entries = esr.get("entries") if isinstance(esr, dict) and isinstance(esr.get("entries"), list) else []
715
+ envelope["esr"] = {
716
+ "wallet": attestation["wallet"],
717
+ "entries": entries,
718
+ "commits": attestation["commits"],
719
+ }
720
+ if binding:
721
+ envelope["order_id"] = binding["order_id"]
722
+ envelope["challenge"] = binding["challenge"]
723
+ return json.dumps(envelope, default=str)
724
+
725
+ def configure_state_registry(self):
726
+ """Make StateRegistry usable from payload code, if ESR is enabled.
727
+
728
+ Wires the module to this enclave's identity key, the SwiftStream client
729
+ and bucket the node shares with us, the registry address baked in at
730
+ build time, and web3. Payload code then just does:
731
+
732
+ from ecld_state import StateRegistry
733
+ StateRegistry().commit("key", lambda s: ...)
734
+
735
+ No-op when ESR is disabled (no ESR_CONTRACT_ADDRESS baked in), so
736
+ non-ESR enclaves are completely unaffected.
737
+
738
+ Never raises: a state-registry setup problem must not stop the task from
739
+ running. Payload code that then uses StateRegistry gets a clear error
740
+ from the registry itself rather than a task that died before starting.
741
+ """
742
+ contract_address = os.getenv("ESR_CONTRACT_ADDRESS", "").strip()
743
+ if not contract_address:
744
+ return
745
+ try:
746
+ import ecld_state
747
+
748
+ ecld_state.configure(
749
+ identity_priv=self.private_key_pem,
750
+ swift_stream_service=self.swift_stream_service,
751
+ bucket=self.etny_bucket,
752
+ contract_address=contract_address,
753
+ web3=self.w3,
754
+ caller=getattr(self, "task_caller", None),
755
+ )
756
+ logging.info(f"StateRegistry ready (ESR {contract_address})")
757
+ except Exception as e:
758
+ logging.error(f"ESR_STATE_ERROR: could not configure StateRegistry: {e}")
759
+
760
+ def validate_trustedzone_data(self):
761
+ """
762
+ Validates signatures of payload and input from trustedzone using the trustedzone public key.
763
+ """
764
+ # Validate payload signature
765
+
766
+ self.__get_trustedzone_public_key()
767
+
768
+ logging.debug(f"Trustedzone Public Key (hex): {self.trustedzone_public_key}")
769
+
770
+ sig_der_payload = bytes.fromhex(self.payload_signature)
771
+ logging.debug(f"Signature DER (hex): {sig_der_payload.hex()}")
772
+ if not crypto.verify_signature(self.trustedzone_public_key, self.payload_data.encode('utf-8'), sig_der_payload):
773
+ logging.error("Payload signature validation failed")
774
+ raise ValueError('INVALID PAYLOAD SIGNATURE') # Or set error state as needed
775
+
776
+ # Validate input signature
777
+ sig_der_input = bytes.fromhex(self.input_signature)
778
+ if not crypto.verify_signature(self.trustedzone_public_key, self.input_data.encode('utf-8'), sig_der_input):
779
+ logging.error("Input signature validation failed")
780
+ raise ValueError('INVALID INPUT SIGNATURE') # Or set error state as needed
781
+
782
+ logging.info("Trustedzone signatures validated successfully")
783
+
784
+ # Authenticated task caller: the DO-request owner, read from the PoX
785
+ # contract by the TRUSTEDZONE and forwarded over this same signed
786
+ # channel (caller.securelock + .sig). Optional -- an older trustedzone
787
+ # does not send it, and the state ACL then treats the task as
788
+ # anonymous. NEVER accepted without a valid trustedzone signature.
789
+ self.task_caller = None
790
+ try:
791
+ caller_value = self.__get_file_content_and_decrypt('caller.securelock')
792
+ caller_sig = self.__get_file_content_and_decrypt('caller.securelock.sig')
793
+ if crypto.verify_signature(
794
+ self.trustedzone_public_key,
795
+ caller_value.encode('utf-8'),
796
+ bytes.fromhex(caller_sig)):
797
+ self.task_caller = caller_value.strip()
798
+ logging.info(f"Task caller attested by trustedzone: {self.task_caller}")
799
+ else:
800
+ logging.error(
801
+ "caller.securelock signature validation FAILED; "
802
+ "treating task as anonymous")
803
+ except Exception as e:
804
+ logging.info(
805
+ f"No attested task caller supplied "
806
+ f"({e.__class__.__name__}); treating task as anonymous")
807
+
808
+ # Task binding: the order id and enclave challenge the trustedzone
809
+ # generated for this task, forwarded as task.securelock + .sig. When
810
+ # present (and only with a valid trustedzone signature) the result and
811
+ # the code are signed over "<challenge>:<order_id>:" and the envelope
812
+ # carries both, so the trustedzone can refuse a result captured from
813
+ # any other run. Absent with an older trustedzone, which then verifies
814
+ # the bare result as before.
815
+ self.task_binding = None
816
+ try:
817
+ task = json.loads(self.get_and_verify_trustedzone_object('task.securelock'))
818
+ order_id = int(task["order_id"])
819
+ challenge = str(task["challenge"])
820
+ if challenge:
821
+ self.task_binding = {"order_id": order_id, "challenge": challenge}
822
+ logging.info(f"Task binding attested by trustedzone: order {order_id}")
823
+ except Exception as e:
824
+ logging.info(
825
+ f"No task binding supplied ({e.__class__.__name__}); "
826
+ f"result will not be bound")
827
+
828
+ def get_and_verify_trustedzone_object(self, object_name: str) -> str:
829
+ """Decrypt a trustedzone-pushed object and verify its signature --
830
+ the same trust checks the payload/input handoff gets. Raises on any
831
+ failure so the session never consumes unauthenticated data."""
832
+ data = self.__get_file_content_and_decrypt(object_name)
833
+ sig_hex = self.__get_file_content_and_decrypt(object_name + '.sig')
834
+ if not crypto.verify_signature(self.trustedzone_public_key,
835
+ data.encode('utf-8'),
836
+ bytes.fromhex(sig_hex)):
837
+ raise ValueError(f"trustedzone signature invalid for {object_name}")
838
+ return data
839
+
840
+ def __get_file_content_and_decrypt(self, object_name: str) -> str:
841
+ encrypted_base64 = self.__get_file_content(object_name)
842
+
843
+ encrypted_tuple = crypto.encrypted_data_from_base64_json(encrypted_base64.encode('utf-8'))
844
+ decrypted_result = crypto.decrypt(self.private_key_pem, encrypted_tuple)
845
+ return decrypted_result.decode('utf-8')
846
+
847
+ def __get_file_content(self, object_name: str) -> str:
848
+ status, content = self.swift_stream_service.get_file_content(self.etny_bucket, object_name)
849
+ if not status:
850
+ raise ValueError(f'Failed to get {object_name} file')
851
+ return content
852
+ def encrypt_file_and_push_to_swifstream(self, file_data: str, file_name: str, signed_prefix: str = ''):
853
+ """
854
+ Encrypts data with trustedzone pubkey, signs signed_prefix + the original
855
+ data with our private key, encrypts the signature, and uploads both to
856
+ storage in separate files. The prefix binds the signature to a context
857
+ (the task binding, for the result and code) without changing the
858
+ uploaded data.
859
+ """
860
+ # Encrypt the data
861
+ encrypted_input = crypto.encrypt_with_pub_key(self.trustedzone_public_key_pem, file_data.encode('utf-8'))
862
+ encrypted_input_base64 = crypto.encrypted_data_to_base64_json(encrypted_input)
863
+ data_file_name = file_name
864
+
865
+ # Sign the original data, with the binding prefix when given
866
+ sig_der = crypto.sign_data(self.private_key_pem, (signed_prefix + file_data).encode('utf-8'))
867
+ sig_hex = sig_der.hex()
868
+
869
+ # Encrypt the signature
870
+ encrypted_sig = crypto.encrypt_with_pub_key(self.trustedzone_public_key_pem, sig_hex.encode('utf-8'))
871
+ encrypted_sig_base64 = crypto.encrypted_data_to_base64_json(encrypted_sig)
872
+ sig_file_name = file_name + '.sig'
873
+
874
+ # Upload encrypted data
875
+ self.swift_stream_service.create_bucket(self.etny_bucket)
876
+ data_io = io.BytesIO(encrypted_input_base64)
877
+ status, _ = self.swift_stream_service.put_file_content(self.etny_bucket, data_file_name, "", data_io)
878
+ if status:
879
+ logging.info(f'File {data_file_name} encrypted and saved to swift stream successfully')
880
+
881
+ # Upload encrypted signature
882
+ sig_io = io.BytesIO(encrypted_sig_base64)
883
+ status_sig, _ = self.swift_stream_service.put_file_content(self.etny_bucket, sig_file_name, "", sig_io)
884
+ if status_sig:
885
+ logging.info(f'Signature file {sig_file_name} encrypted and saved to swift stream successfully')
886
+
887
+ def __get_trustedzone_public_key(self):
888
+ logging.info('Getting the public key of the trusted zone enclave')
889
+ image_name = self._metadata.trustedzone_image_name
890
+ self.trustedzone_image_hash, self.trustedzone_public_key_pem, self.trustedzone_composer_hash = self.image_registry.caller().getLatestTrustedZoneImageCertPublicKey(image_name, 'v3')
891
+
892
+ # Parse PEM to extract raw public key hex (x + y coordinates, no '04' prefix)
893
+ from cryptography.x509 import load_pem_x509_certificate
894
+ cert = load_pem_x509_certificate(self.trustedzone_public_key_pem.encode('utf-8'), default_backend())
895
+ pub_key = cert.public_key()
896
+ pub_bytes = pub_key.public_bytes(
897
+ encoding=serialization.Encoding.X962,
898
+ format=serialization.PublicFormat.UncompressedPoint
899
+ )[1:] # Slice off the '04' uncompressed prefix for raw format
900
+ self.trustedzone_public_key = pub_bytes.hex()
901
+
902
+ logging.debug(f'Extracted trustedzone public key hex: {self.trustedzone_public_key[:20]}...') # Log snippet for confirmation
903
+
904
+
905
+ def _report_failure(app, exc):
906
+ """Best-effort: write a failure result so the trustedzone stops waiting.
907
+
908
+ Securelock only publishes the result (and its code); the trustedzone reads
909
+ those and builds the signed transaction. Guarded throughout -- this runs
910
+ while already handling a failure, so it must never raise or hang. If the
911
+ enclave died before it had enough state (no app, no keys), there is nothing
912
+ publishable and we exit quietly.
913
+ """
914
+ if app is None:
915
+ logging.error("cannot report failure: enclave died before initialization")
916
+ return
917
+ try:
918
+ # Preserve a specific code if one was already set (checksum mismatch,
919
+ # metadata fetch, ...); fall back to SYSTEM_ERROR for anything else.
920
+ if not getattr(app, 'task_code', None):
921
+ app.task_code = TaskStatus.SYSTEM_ERROR
922
+ if not getattr(app, 'task_result', None):
923
+ app.task_result = f'ENCLAVE ERROR: {exc}'
924
+ app.save_result()
925
+ logging.info(f"failure reported (task_code={app.task_code})")
926
+ except Exception as report_exc:
927
+ logging.error(f"could not report the failure result: {report_exc}")
928
+
929
+
930
+ if __name__ == '__main__':
931
+ network_type = '__NETWORK_TYPE__'
932
+ logging.info(f'[SecureLock] Initializing enclave for {network_type}')
933
+
934
+ # Determine paths based on debug mode
935
+ key_file = "/private/__SECURELOCK_SESSION__/key.pem"
936
+ cert_file = "/app/__SECURELOCK_SESSION__/cert.pem"
937
+ pub_cert_file = "/app/__SECURELOCK_SESSION__/enclave_pub_cert.pem"
938
+ # A CAS-provisioned build loads the key and cert the CAS injected from the
939
+ # session: mainnet (Scontain CAS), and a testnet with a ValidatorRegistry
940
+ # baked in (ethernity-cas -- the same registry verify_cas_attestation
941
+ # checks the CAS against). A testnet build with no registry self-signs
942
+ # from MR_ENCLAVE.
943
+ validator_registry = '__VALIDATOR_REGISTRY_ADDRESS__'
944
+ cas_provisioned = network_type == 'mainnet' or (
945
+ bool(validator_registry) and not validator_registry.startswith('__'))
946
+ if cas_provisioned and os.path.exists(key_file) and os.path.exists(cert_file):
947
+ logging.info("Loading key and cert from session")
948
+ with open(key_file, 'r+b') as f:
949
+ private_key_pem = f.read()
950
+ f.truncate(0)
951
+ with open(cert_file, 'rb') as f:
952
+ cert_pem = f.read()
953
+ elif network_type == 'testnet' and not cas_provisioned:
954
+ logging.info("No existing key/cert files found")
955
+ private_key_pem, cert_pem = EtnySecureLock.generate_cert_from_mrenclave()
956
+ else:
957
+ logging.error(f"Invalid encalve session or network type")
958
+ exit(1)
959
+
960
+ # Emit the enclave public certificate IMMEDIATELY, before touching
961
+ # SwiftStream. The public-key extraction service runs securelock in an
962
+ # environment with NO etny-swift-stream (MinIO) container; the first
963
+ # SwiftStream call below (get_file_content for .env) then blocks on a TCP
964
+ # connect to a non-existent host with no timeout, so the enclave hangs and
965
+ # the extractor's getPublicKey times out WITHOUT ever receiving the cert.
966
+ # The cert is already in hand (generated from MR_ENCLAVE above / loaded from
967
+ # the CAS session) and needs nothing from SwiftStream, so print it first.
968
+ # Real task runs also benefit: the cert is available up-front and the same
969
+ # PUBLIC_CERT line is still printed in the except handler for compatibility.
970
+ logging.info(f"PUBLIC_CERT: {cert_pem.decode('utf-8')}")
971
+
972
+ # ESR (RFC §5.2): emit the enclave's ESR wallet ADDRESS on the same channel
973
+ # as the cert, so publish can capture it locally or via the remote
974
+ # extraction service. It MUST be derived here rather than by the extraction
975
+ # service: on mainnet the identity key is CAS-provisioned and exists only
976
+ # inside this enclave, so nobody outside can compute the address. Only the
977
+ # address is emitted — the private key never leaves.
978
+ if os.getenv("ESR_CONTRACT_ADDRESS", "").strip():
979
+ try:
980
+ esr_address = esr_wallet.derive_wallet_address(private_key_pem)
981
+ logging.info(f"ESR_WALLET_ADDRESS: {esr_address}")
982
+ if not esr_wallet.is_secret_identity(network_type, attested=cas_provisioned):
983
+ logging.warning(esr_wallet.INSECURE_IDENTITY_WARNING)
984
+ except Exception as e:
985
+ # Never let ESR derivation break a task: report it the way every
986
+ # other enclave-side config problem is reported.
987
+ logging.error(f"ESR_WALLET_ERROR: {e}")
988
+
989
+ # Cert-harvest (public-key extraction) mode: the extraction service runs
990
+ # securelock with NO SwiftStream and NO task, purely to read the PUBLIC_CERT
991
+ # printed above. In that environment the .env fetch below fails (no MinIO).
992
+ # The cert is already out, so EXIT(0) cleanly instead of proceeding to
993
+ # wait_for_payload_and_input() -- which would block forever waiting for a task
994
+ # that never comes, hanging the extractor until its 20-minute timeout. (The
995
+ # extractor captures the cert from stdout when this process exits.)
996
+ try:
997
+ swiftStreamClient = SwiftStreamService("etny-swift-stream:9000", "swiftstreamadmin", "swiftstreamadmin")
998
+ status, env_content = swiftStreamClient.get_file_content("__BUCKET_NAME__", ".env")
999
+ except Exception as e:
1000
+ logging.info(f"SwiftStream unavailable ({e}); public key emitted, exiting (cert-harvest mode)")
1001
+ exit(0)
1002
+ if not status:
1003
+ logging.info("No .env in SwiftStream; public key emitted, exiting (cert-harvest mode)")
1004
+ exit(0)
1005
+
1006
+ try:
1007
+ EtnySecureLock.read_env_str(env_content)
1008
+
1009
+ app = EtnySecureLock(cert_pem, private_key_pem, swiftStreamClient)
1010
+
1011
+ logging.info('Waiting for payload and input')
1012
+ app.wait_for_payload_and_input()
1013
+
1014
+ logging.info('Validate client payload and input')
1015
+ app.validate_client_payload()
1016
+ if app.is_valid_client_data:
1017
+ logging.info('Client payload and input are valid')
1018
+ else:
1019
+ logging.error('Client payload and input are NOT valid')
1020
+ raise ValueError(app.task_result)
1021
+
1022
+ app.execute()
1023
+
1024
+ app.save_result()
1025
+
1026
+ logging.info('Finished the execution')
1027
+ except Exception as e:
1028
+ logging.error(f"Error: {e}")
1029
+ logging.info(f"PUBLIC_CERT: {cert_pem.decode('utf-8')}")
1030
+ # Publish a failure result rather than exiting silently. The trustedzone
1031
+ # blocks waiting for this enclave's result; without it that wait runs to
1032
+ # its timeout and the whole order stalls with no diagnosis. Note the
1033
+ # validation path above RAISES on invalid client data, so without this
1034
+ # even a checksum mismatch -- which sets task_code/task_result -- was
1035
+ # never reported.
1036
+ _report_failure(locals().get('app'), e)
1037
+ exit(1)