@ethernity-cloud/sdk-js 1.6.2 → 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.
- package/README.md +41 -3
- package/cas/config.js +59 -1
- package/info.js +14 -5
- package/init.js +20 -4
- 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/package.json +5 -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/serverless/Dockerfile.serverless +8 -0
- package/pynithy/src/serverless/requirements.txt +6 -0
- package/run.js +69 -28
- package/nodenithy/build/las/Dockerfile +0 -5
- 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
|
@@ -1,731 +1,1037 @@
|
|
|
1
|
-
#!/usr/bin/python
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import os
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
from
|
|
22
|
-
from
|
|
23
|
-
from
|
|
24
|
-
from
|
|
25
|
-
from
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
self.
|
|
45
|
-
self.
|
|
46
|
-
self.
|
|
47
|
-
self.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
self.
|
|
77
|
-
self.
|
|
78
|
-
self.
|
|
79
|
-
self.
|
|
80
|
-
self.
|
|
81
|
-
self.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
#
|
|
429
|
-
#
|
|
430
|
-
# and
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
#
|
|
576
|
-
#
|
|
577
|
-
#
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
self.
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
self.
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
#
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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)
|