@ethernity-cloud/sdk-js 1.5.16 → 1.5.17
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/info.js +3 -3
- package/nodenithy/build/securelock/.ecld-test-stage/ecld_state.js +815 -780
- package/nodenithy/build/securelock/.ecld-test-stage/esr_local.js +5 -4
- package/nodenithy/build/securelock/.ecld-test-stage/nonce_ladder.js +165 -146
- package/nodenithy/build/securelock/src/ecld_state.js +815 -780
- package/nodenithy/build/securelock/src/esr_local.js +5 -4
- package/nodenithy/build/securelock/src/etny_exec.js +5 -0
- package/nodenithy/build/securelock/src/task_status.js +6 -0
- package/package.json +1 -1
- package/test.js +2 -0
|
@@ -1,780 +1,815 @@
|
|
|
1
|
-
// Enclave State Registry (ESR) — in-enclave state API (Nodenithy).
|
|
2
|
-
//
|
|
3
|
-
// Gives payload code durable, encrypted state across tasks:
|
|
4
|
-
//
|
|
5
|
-
// const { StateRegistry } = require('./ecld_state');
|
|
6
|
-
// const state = new StateRegistry();
|
|
7
|
-
// await state.commit('my-key', (s) => ({ ...s, n: (s.n || 0) + 1 }));
|
|
8
|
-
//
|
|
9
|
-
// State is encrypted inside the enclave, stored as an IPFS object, and only a
|
|
10
|
-
// POINTER (the CID) plus a version goes on-chain. The registry keeps one entry
|
|
11
|
-
// per (enclave, key) with monotonic versions and optimistic concurrency.
|
|
12
|
-
//
|
|
13
|
-
// ## Why the enclave computes the CID itself
|
|
14
|
-
//
|
|
15
|
-
// The node is untrusted. If it told the enclave which CID to commit, a
|
|
16
|
-
// malicious operator could pin the enclave's blob but return the CID of
|
|
17
|
-
// DIFFERENT content; the enclave would sign that onto the chain, and clients
|
|
18
|
-
// would fetch attacker-chosen state believing the enclave authored it.
|
|
19
|
-
//
|
|
20
|
-
// A CID is a hash of the content, so the enclave derives it from the bytes it
|
|
21
|
-
// just encrypted (CIDv1/raw = base32(0x01 0x55 0x12 0x20 || sha256(content)))
|
|
22
|
-
// and commits that. The node pins and verifies but never chooses. It also means
|
|
23
|
-
// the enclave never waits on the node: it writes the blob, writes the CID,
|
|
24
|
-
// commits, and moves on.
|
|
25
|
-
//
|
|
26
|
-
// ## Encryption
|
|
27
|
-
//
|
|
28
|
-
// State is encrypted with a key derived from the enclave identity under a
|
|
29
|
-
// SECOND domain separator (distinct from the ESR wallet's), so the state key
|
|
30
|
-
// cannot collide with the wallet key. State therefore inherits exactly the
|
|
31
|
-
// identity's security: enclave-only on mainnet, reproducible on testnet — which
|
|
32
|
-
// is the accepted testnet posture (functional testing, not secrecy).
|
|
33
|
-
//
|
|
34
|
-
// SECURITY: the state key and the wallet key never leave this module. Only the
|
|
35
|
-
// address and the CID are ever emitted.
|
|
36
|
-
const crypto = require('crypto');
|
|
37
|
-
const { ethers } = require('ethers');
|
|
38
|
-
const esr_wallet = require('./esr_wallet');
|
|
39
|
-
|
|
40
|
-
const DOMAIN_SEP_STATE = 'ethernity-cloud/esr-encryption/v1';
|
|
41
|
-
|
|
42
|
-
// AES-GCM parameters, pinned explicitly so the blob layout never depends on a
|
|
43
|
-
// library default: nonce(12) || tag(16) || ciphertext.
|
|
44
|
-
const NONCE_LEN = 12;
|
|
45
|
-
const TAG_LEN = 16;
|
|
46
|
-
|
|
47
|
-
let _identityPriv = null;
|
|
48
|
-
let _swift = null;
|
|
49
|
-
let _bucket = null;
|
|
50
|
-
let _contractAddress = null;
|
|
51
|
-
let _provider = null;
|
|
52
|
-
|
|
53
|
-
// LOCAL TESTING ONLY (esr_local). In the real enclave these stay null: the node
|
|
54
|
-
// applies commitFor on-chain and _contract() builds a real ethers.Contract. The
|
|
55
|
-
// local ESR emulator overrides the "contract" with an in-memory object and
|
|
56
|
-
// applies commits itself, so ecld-test runs state-using backends with no chain.
|
|
57
|
-
let _contractOverride = null;
|
|
58
|
-
let _localCommitApply = null;
|
|
59
|
-
|
|
60
|
-
function setContractOverride(obj) { _contractOverride = obj; }
|
|
61
|
-
function setLocalCommitApply(fn) { _localCommitApply = fn; }
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Wire the registry to the enclave's identity, storage and chain access.
|
|
65
|
-
* Called by securelock at task start; payload code never calls this.
|
|
66
|
-
*/
|
|
67
|
-
function configure({ identityPriv, swiftStreamService, bucket, contractAddress, provider, caller = null }) {
|
|
68
|
-
_identityPriv = identityPriv;
|
|
69
|
-
_swift = swiftStreamService;
|
|
70
|
-
_bucket = bucket;
|
|
71
|
-
_contractAddress = contractAddress;
|
|
72
|
-
_provider = provider;
|
|
73
|
-
_esrLedger = [];
|
|
74
|
-
_taskNonces = {};
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
//
|
|
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
|
-
// commit
|
|
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
|
-
} catch (e) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
return
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
return
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
function
|
|
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
|
-
return
|
|
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
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
return
|
|
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
|
-
if (!
|
|
407
|
-
throw new
|
|
408
|
-
`
|
|
409
|
-
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
const
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
await
|
|
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
|
-
return
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
);
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
function
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
}
|
|
1
|
+
// Enclave State Registry (ESR) — in-enclave state API (Nodenithy).
|
|
2
|
+
//
|
|
3
|
+
// Gives payload code durable, encrypted state across tasks:
|
|
4
|
+
//
|
|
5
|
+
// const { StateRegistry } = require('./ecld_state');
|
|
6
|
+
// const state = new StateRegistry();
|
|
7
|
+
// await state.commit('my-key', (s) => ({ ...s, n: (s.n || 0) + 1 }));
|
|
8
|
+
//
|
|
9
|
+
// State is encrypted inside the enclave, stored as an IPFS object, and only a
|
|
10
|
+
// POINTER (the CID) plus a version goes on-chain. The registry keeps one entry
|
|
11
|
+
// per (enclave, key) with monotonic versions and optimistic concurrency.
|
|
12
|
+
//
|
|
13
|
+
// ## Why the enclave computes the CID itself
|
|
14
|
+
//
|
|
15
|
+
// The node is untrusted. If it told the enclave which CID to commit, a
|
|
16
|
+
// malicious operator could pin the enclave's blob but return the CID of
|
|
17
|
+
// DIFFERENT content; the enclave would sign that onto the chain, and clients
|
|
18
|
+
// would fetch attacker-chosen state believing the enclave authored it.
|
|
19
|
+
//
|
|
20
|
+
// A CID is a hash of the content, so the enclave derives it from the bytes it
|
|
21
|
+
// just encrypted (CIDv1/raw = base32(0x01 0x55 0x12 0x20 || sha256(content)))
|
|
22
|
+
// and commits that. The node pins and verifies but never chooses. It also means
|
|
23
|
+
// the enclave never waits on the node: it writes the blob, writes the CID,
|
|
24
|
+
// commits, and moves on.
|
|
25
|
+
//
|
|
26
|
+
// ## Encryption
|
|
27
|
+
//
|
|
28
|
+
// State is encrypted with a key derived from the enclave identity under a
|
|
29
|
+
// SECOND domain separator (distinct from the ESR wallet's), so the state key
|
|
30
|
+
// cannot collide with the wallet key. State therefore inherits exactly the
|
|
31
|
+
// identity's security: enclave-only on mainnet, reproducible on testnet — which
|
|
32
|
+
// is the accepted testnet posture (functional testing, not secrecy).
|
|
33
|
+
//
|
|
34
|
+
// SECURITY: the state key and the wallet key never leave this module. Only the
|
|
35
|
+
// address and the CID are ever emitted.
|
|
36
|
+
const crypto = require('crypto');
|
|
37
|
+
const { ethers } = require('ethers');
|
|
38
|
+
const esr_wallet = require('./esr_wallet');
|
|
39
|
+
|
|
40
|
+
const DOMAIN_SEP_STATE = 'ethernity-cloud/esr-encryption/v1';
|
|
41
|
+
|
|
42
|
+
// AES-GCM parameters, pinned explicitly so the blob layout never depends on a
|
|
43
|
+
// library default: nonce(12) || tag(16) || ciphertext.
|
|
44
|
+
const NONCE_LEN = 12;
|
|
45
|
+
const TAG_LEN = 16;
|
|
46
|
+
|
|
47
|
+
let _identityPriv = null;
|
|
48
|
+
let _swift = null;
|
|
49
|
+
let _bucket = null;
|
|
50
|
+
let _contractAddress = null;
|
|
51
|
+
let _provider = null;
|
|
52
|
+
|
|
53
|
+
// LOCAL TESTING ONLY (esr_local). In the real enclave these stay null: the node
|
|
54
|
+
// applies commitFor on-chain and _contract() builds a real ethers.Contract. The
|
|
55
|
+
// local ESR emulator overrides the "contract" with an in-memory object and
|
|
56
|
+
// applies commits itself, so ecld-test runs state-using backends with no chain.
|
|
57
|
+
let _contractOverride = null;
|
|
58
|
+
let _localCommitApply = null;
|
|
59
|
+
|
|
60
|
+
function setContractOverride(obj) { _contractOverride = obj; }
|
|
61
|
+
function setLocalCommitApply(fn) { _localCommitApply = fn; }
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Wire the registry to the enclave's identity, storage and chain access.
|
|
65
|
+
* Called by securelock at task start; payload code never calls this.
|
|
66
|
+
*/
|
|
67
|
+
function configure({ identityPriv, swiftStreamService, bucket, contractAddress, provider, caller = null }) {
|
|
68
|
+
_identityPriv = identityPriv;
|
|
69
|
+
_swift = swiftStreamService;
|
|
70
|
+
_bucket = bucket;
|
|
71
|
+
_contractAddress = contractAddress;
|
|
72
|
+
_provider = provider;
|
|
73
|
+
_esrLedger = [];
|
|
74
|
+
_taskNonces = {};
|
|
75
|
+
_taskRelayNonces = {};
|
|
76
|
+
_taskLedger = {};
|
|
77
|
+
_taskCaller = normAddr(caller);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The authenticated task caller: the wallet that placed the DO request, read
|
|
81
|
+
// from the PoX contract by the TRUSTEDZONE and forwarded over its signed
|
|
82
|
+
// handoff (caller.securelock + .sig, verified by securelock before configure).
|
|
83
|
+
// null when the trustedzone did not supply one -- anonymity is never a
|
|
84
|
+
// privilege: null cannot claim ownership or touch owned state.
|
|
85
|
+
let _taskCaller = null;
|
|
86
|
+
|
|
87
|
+
function taskCaller() {
|
|
88
|
+
return _taskCaller;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normAddr(addr) {
|
|
92
|
+
return typeof addr === 'string' && addr ? addr.toLowerCase() : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* State container + ACL. Stored blobs are either legacy (raw state --
|
|
97
|
+
* "unowned") or an owned container:
|
|
98
|
+
* { _ecld_state: 1, acl: {...}, data: <state> }
|
|
99
|
+
* acl = { owner, read: [], write: [], public_read: false }. Enforcement
|
|
100
|
+
* happens HERE, inside the enclave, against the trustedzone-attested caller.
|
|
101
|
+
*/
|
|
102
|
+
const CONTAINER_MARK = '_ecld_state';
|
|
103
|
+
|
|
104
|
+
class StatePermissionError extends Error {}
|
|
105
|
+
|
|
106
|
+
// A commit's idempotency nonce was already used: the dApp supplied `nonce` on
|
|
107
|
+
// commit() and it is not greater than the last accepted nonce for the key --
|
|
108
|
+
// a duplicate (or out-of-order) submission. The state was NOT changed.
|
|
109
|
+
class StateNonceError extends Error {}
|
|
110
|
+
|
|
111
|
+
// Per-run cap on state commits: bounds the number of relayed transactions the
|
|
112
|
+
// node must track/pay and the size of the adjudicated ledger bound into the
|
|
113
|
+
// result transaction. 256 so the on-chain count fits ONE BYTE (bias-1:
|
|
114
|
+
// 0x00 = 1 commit ... 0xFF = 256; an all-zero root means no commits at all).
|
|
115
|
+
// The over-limit commit is NOT applied; earlier commits stand. Surfaces as
|
|
116
|
+
// task code 38 (ESR_COMMIT_LIMIT_EXCEEDED).
|
|
117
|
+
const MAX_COMMITS_PER_TASK = 256;
|
|
118
|
+
|
|
119
|
+
class StateLimitError extends Error {}
|
|
120
|
+
|
|
121
|
+
function unwrapStored(stored) {
|
|
122
|
+
if (stored && typeof stored === 'object' && stored[CONTAINER_MARK] === 1) {
|
|
123
|
+
return [stored.acl || null, stored.data];
|
|
124
|
+
}
|
|
125
|
+
return [null, stored];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function wrapStored(acl, data, committedBy = null, nonce = null) {
|
|
129
|
+
// For owned state, bind the authoring caller (committedBy) INTO the blob, so
|
|
130
|
+
// it lands in the CID -- the field the securelock signs -- making the caller
|
|
131
|
+
// cryptographically bound to the commit, not merely a re-stampable sidecar.
|
|
132
|
+
// Unowned state keeps the bare shape (no ACL, no binding) -- unless a nonce
|
|
133
|
+
// must be carried, which forces the container shape.
|
|
134
|
+
//
|
|
135
|
+
// `nonce` is a REPORTING COPY of the registry's stored idempotency nonce as
|
|
136
|
+
// of this commit. The on-chain value (getNonce) is always the primary; the
|
|
137
|
+
// blob copy exists so the signed CID cryptographically commits to the nonce
|
|
138
|
+
// the enclave intended, and reads verify the two match (_readContainer).
|
|
139
|
+
if (!acl && nonce == null) return data;
|
|
140
|
+
const container = { [CONTAINER_MARK]: 1, acl: acl || null, data };
|
|
141
|
+
if (committedBy) container.committedBy = committedBy;
|
|
142
|
+
if (nonce != null) container.nonce = Math.trunc(nonce);
|
|
143
|
+
return container;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Post-payload: re-assert the trusted caller across the ESR ledger. Called by
|
|
148
|
+
* the securelock after the payload returns, with the attested caller captured
|
|
149
|
+
* before execution (outside the payload's reach). Overwrites callerUsed (and
|
|
150
|
+
* re-fixes enclave to the real signer) in every owned entry and RE-STAGES the
|
|
151
|
+
* ledger, so a forged callerUsed cannot reach the trustedzone. Returns true if
|
|
152
|
+
* any entry's callerUsed differed from the trusted value (tamper detected).
|
|
153
|
+
* Defence in depth -- the trustedzone and the CID-bound caller are the sound
|
|
154
|
+
* layers; this runs in the same process the payload subverted.
|
|
155
|
+
*/
|
|
156
|
+
async function restampLedgerCaller(trustedCaller) {
|
|
157
|
+
const trusted = normAddr(trustedCaller);
|
|
158
|
+
if (!_esrLedger.length) return false;
|
|
159
|
+
let tampered = false;
|
|
160
|
+
let signerAddr = null;
|
|
161
|
+
try {
|
|
162
|
+
signerAddr = await new StateRegistry()._signer().getAddress();
|
|
163
|
+
} catch (e) { signerAddr = null; }
|
|
164
|
+
for (const entry of _esrLedger) {
|
|
165
|
+
if (!entry.owned) continue;
|
|
166
|
+
if (normAddr(entry.callerUsed) !== trusted) tampered = true;
|
|
167
|
+
entry.callerUsed = trusted || '';
|
|
168
|
+
if (signerAddr) entry.enclave = signerAddr;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
await _swift.putFileContent(
|
|
172
|
+
_bucket, 'esr.authorizations.json', '',
|
|
173
|
+
Buffer.from(JSON.stringify(_esrLedger), 'utf8'));
|
|
174
|
+
} catch (e) { /* best-effort restage; trustedzone still adjudicates */ }
|
|
175
|
+
return tampered;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function aclMembers(acl, field) {
|
|
179
|
+
return new Set((acl[field] || []).map(normAddr).filter(Boolean));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function canRead(acl) {
|
|
183
|
+
if (!acl || acl.public_read) return true;
|
|
184
|
+
const c = _taskCaller;
|
|
185
|
+
if (!c) return false;
|
|
186
|
+
return c === normAddr(acl.owner) || aclMembers(acl, 'read').has(c) || aclMembers(acl, 'write').has(c);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function canWrite(acl) {
|
|
190
|
+
if (!acl) return true; // unowned: writable; claimed when a caller exists
|
|
191
|
+
const c = _taskCaller;
|
|
192
|
+
if (!c) return false;
|
|
193
|
+
return c === normAddr(acl.owner) || aclMembers(acl, 'write').has(c);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function newAcl(owner) {
|
|
197
|
+
return { owner, read: [], write: [], public_read: false };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Task-scoped ledger of every state key this execution touched, recorded by
|
|
201
|
+
// get()/commit(). ecldResult() snapshots it to attach fresh state to the task
|
|
202
|
+
// result, so callers (and the runner's state cache) get current state in the
|
|
203
|
+
// same result -- no separate read task needed. Reset per task in configure().
|
|
204
|
+
let _taskLedger = {};
|
|
205
|
+
|
|
206
|
+
function ledgerRecord(key, version, cid, state) {
|
|
207
|
+
_taskLedger[key] = { key, version: Number(version), cid, state };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The `esr` attachment for ecldResult: wallet + entries. Synchronous by
|
|
212
|
+
* design (the securelock consumes results synchronously): entries come from
|
|
213
|
+
* the in-memory ledger of keys this task already touched. To attach a key the
|
|
214
|
+
* task did not touch, use the async esrFetch() helper, which awaits the reads
|
|
215
|
+
* (populating the ledger) before building the result.
|
|
216
|
+
*/
|
|
217
|
+
function ledgerSnapshot(includeState = true, keys = null) {
|
|
218
|
+
const reg = new StateRegistry();
|
|
219
|
+
const wanted = keys ? keys : Object.keys(_taskLedger);
|
|
220
|
+
const entries = [];
|
|
221
|
+
for (const k of wanted) {
|
|
222
|
+
const e = _taskLedger[k];
|
|
223
|
+
if (!e) continue;
|
|
224
|
+
const entry = { ...e };
|
|
225
|
+
if (!includeState) delete entry.state;
|
|
226
|
+
entries.push(entry);
|
|
227
|
+
}
|
|
228
|
+
return { wallet: reg.walletAddress, entries };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function keccak256(buf) {
|
|
232
|
+
return Buffer.from(ethers.utils.keccak256(buf).slice(2), 'hex');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* AES key for state, derived under its own domain separator. A separate
|
|
237
|
+
* separator from the wallet's means the two keys are provably unrelated:
|
|
238
|
+
* compromising one does not yield the other.
|
|
239
|
+
*/
|
|
240
|
+
function stateKey() {
|
|
241
|
+
if (!_identityPriv) {
|
|
242
|
+
throw new Error('StateRegistry is not configured (no enclave identity)');
|
|
243
|
+
}
|
|
244
|
+
const material = Buffer.isBuffer(_identityPriv)
|
|
245
|
+
? _identityPriv
|
|
246
|
+
: Buffer.from(String(_identityPriv), 'utf8');
|
|
247
|
+
return keccak256(Buffer.concat([Buffer.from(DOMAIN_SEP_STATE, 'utf8'), material]));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* CIDv1/raw/sha2-256 for `content`, computed without touching IPFS.
|
|
252
|
+
* Matches `ipfs add --cid-version=1 --raw-leaves`, which is what lets the
|
|
253
|
+
* enclave commit a CID it derived itself rather than one a node handed it.
|
|
254
|
+
*/
|
|
255
|
+
function cidv1Raw(content) {
|
|
256
|
+
const digest = crypto.createHash('sha256').update(content).digest();
|
|
257
|
+
const raw = Buffer.concat([Buffer.from([0x01, 0x55, 0x12, 0x20]), digest]);
|
|
258
|
+
return 'b' + base32Encode(raw).toLowerCase().replace(/=+$/, '');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// RFC4648 base32, no padding stripped here (the caller strips it).
|
|
262
|
+
function base32Encode(buf) {
|
|
263
|
+
const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
264
|
+
let bits = 0;
|
|
265
|
+
let value = 0;
|
|
266
|
+
let out = '';
|
|
267
|
+
for (const byte of buf) {
|
|
268
|
+
value = (value << 8) | byte;
|
|
269
|
+
bits += 8;
|
|
270
|
+
while (bits >= 5) {
|
|
271
|
+
out += A[(value >>> (bits - 5)) & 31];
|
|
272
|
+
bits -= 5;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (bits > 0) out += A[(value << (5 - bits)) & 31];
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* True only for values shaped like an IPFS CID.
|
|
281
|
+
*
|
|
282
|
+
* The contract accepts any non-empty string as the pointer, so a buggy writer
|
|
283
|
+
* can commit something that is not a CID (the live registry holds one 0x…
|
|
284
|
+
* digest). That is a defect in the writer, not a supported format: such an
|
|
285
|
+
* entry is rejected rather than fetched, because passing it on means an error
|
|
286
|
+
* at best and a retry-loop at worst.
|
|
287
|
+
*/
|
|
288
|
+
function looksLikeCID(value) {
|
|
289
|
+
const cid = (value || '').trim();
|
|
290
|
+
if (!cid || cid.startsWith('0x')) return false;
|
|
291
|
+
if (cid.startsWith('Qm') && cid.length === 46) return true;
|
|
292
|
+
if (cid.startsWith('b') && cid.length >= 46 && cid === cid.toLowerCase()) return true;
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function encrypt(plaintext) {
|
|
297
|
+
const nonce = crypto.randomBytes(NONCE_LEN);
|
|
298
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', stateKey(), nonce, {
|
|
299
|
+
authTagLength: TAG_LEN
|
|
300
|
+
});
|
|
301
|
+
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
302
|
+
return Buffer.concat([nonce, cipher.getAuthTag(), ct]);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function decrypt(blob) {
|
|
306
|
+
if (blob.length < NONCE_LEN + TAG_LEN) {
|
|
307
|
+
throw new Error('state blob is too short to be valid');
|
|
308
|
+
}
|
|
309
|
+
const nonce = blob.subarray(0, NONCE_LEN);
|
|
310
|
+
const tag = blob.subarray(NONCE_LEN, NONCE_LEN + TAG_LEN);
|
|
311
|
+
const ct = blob.subarray(NONCE_LEN + TAG_LEN);
|
|
312
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', stateKey(), nonce, {
|
|
313
|
+
authTagLength: TAG_LEN
|
|
314
|
+
});
|
|
315
|
+
decipher.setAuthTag(tag);
|
|
316
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The bytes32 the contract is keyed by: keccak256(key). */
|
|
320
|
+
function keyHash(key) {
|
|
321
|
+
return ethers.utils.id(key);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const ESR_ABI = [
|
|
325
|
+
'function commit(bytes32 key, string newCID, uint256 expectedVersion, uint256 nonce)',
|
|
326
|
+
'function commitFor(address enclave, bytes32 key, string newCID, uint256 expectedVersion, uint256 relayNonce, uint256 nonce, bytes signature)',
|
|
327
|
+
'function commitDigest(address enclave, bytes32 key, string newCID, uint256 expectedVersion, uint256 relayNonce, uint256 nonce) view returns (bytes32)',
|
|
328
|
+
'function relayNonce(address enclave, bytes32 key) view returns (uint256)',
|
|
329
|
+
'function getState(address enclave, bytes32 key) view returns (string cid, uint256 version, uint64 updatedAt)',
|
|
330
|
+
'function getVersion(address enclave, bytes32 key) view returns (uint256)',
|
|
331
|
+
'function getNonce(address enclave, bytes32 key) view returns (uint256)',
|
|
332
|
+
'function exists(address enclave, bytes32 key) view returns (bool)'
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
// Order-wide ledger of signed authorizations. ESR commits do NOT pay their own
|
|
336
|
+
// gas (see contracts/esr/RELAY-DESIGN.md): the enclave only SIGNS each commit
|
|
337
|
+
// (commitFor); the NODE relays it and pays, and does all gas accounting. The
|
|
338
|
+
// securelock does no gas math -- nothing it could claim about cost is trusted.
|
|
339
|
+
let _esrLedger = [];
|
|
340
|
+
|
|
341
|
+
// Per-task memo of the last idempotency nonce ACCEPTED per key, so a
|
|
342
|
+
// getNonce() right after a commit in the SAME task returns the fresh value
|
|
343
|
+
// even before the node's relay lands on-chain.
|
|
344
|
+
let _taskNonces = {};
|
|
345
|
+
|
|
346
|
+
// Per-task memo of the NEXT relay nonce per key hash, so multiple commits to
|
|
347
|
+
// the same key within one task sign sequential relay nonces even while the
|
|
348
|
+
// node is still relaying the earlier ones.
|
|
349
|
+
let _taskRelayNonces = {};
|
|
350
|
+
|
|
351
|
+
class StateRegistry {
|
|
352
|
+
constructor() {
|
|
353
|
+
if (!_contractAddress) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
'ESR is not enabled for this enclave. Enable it (ECLD_ESR_ENABLE) and ' +
|
|
356
|
+
'rebuild, so the registry address is baked into the image.'
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
this._priv = null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The enclave's on-chain address — safe to publish and to fund. */
|
|
363
|
+
get walletAddress() {
|
|
364
|
+
return esr_wallet.deriveWalletAddress(_identityPriv);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Alias of walletAddress — the ENCLAVE's own on-chain address (its identity,
|
|
369
|
+
* and the namespace all its state keys live under). Preferred name in new
|
|
370
|
+
* code; walletAddress remains for compatibility.
|
|
371
|
+
*/
|
|
372
|
+
get enclaveAddress() {
|
|
373
|
+
return this.walletAddress;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
_signer() {
|
|
377
|
+
// Reuse esr_wallet's derivation rather than repeating it here: two copies
|
|
378
|
+
// would eventually disagree, and the address a client funds would stop
|
|
379
|
+
// matching the key that signs. The key stays inside this method.
|
|
380
|
+
if (this._priv === null) {
|
|
381
|
+
this._priv = esr_wallet._deriveWalletPrivateKey(_identityPriv);
|
|
382
|
+
}
|
|
383
|
+
return new ethers.Wallet(this._priv, _provider);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
_contract(signerOrProvider) {
|
|
387
|
+
// Local emulator: use the in-memory contract object instead of a real one.
|
|
388
|
+
if (_contractOverride) return _contractOverride;
|
|
389
|
+
return new ethers.Contract(_contractAddress, ESR_ABI, signerOrProvider || _provider);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Current on-chain version for `key`; 0 when never committed. */
|
|
393
|
+
async getVersion(key) {
|
|
394
|
+
const v = await this._contract().getVersion(this.walletAddress, keyHash(key));
|
|
395
|
+
return Number(v);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Decrypted state for `key`, or `fallback` ({} by default) when absent.
|
|
400
|
+
*
|
|
401
|
+
* Throws on a pointer that is not a CID: treating a broken pointer as "no
|
|
402
|
+
* state" would let the next commit overwrite data that is still recoverable.
|
|
403
|
+
*/
|
|
404
|
+
async get(key, fallback = {}) {
|
|
405
|
+
const [acl, data, version, cid] = await this._readContainer(key, fallback);
|
|
406
|
+
if (!canRead(acl)) {
|
|
407
|
+
throw new StatePermissionError(
|
|
408
|
+
`caller ${_taskCaller || '<anonymous>'} has no read permission on ` +
|
|
409
|
+
`state key '${key}' (owner: ${acl.owner})`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
ledgerRecord(key, version || 0, cid, data);
|
|
413
|
+
return data;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** [acl, data, version, cid] for `key`; NO permission check here. */
|
|
417
|
+
async _readContainer(key, fallback = {}) {
|
|
418
|
+
const [cid, version] = await this._contract().getState(this.walletAddress, keyHash(key));
|
|
419
|
+
if (!version || !cid) {
|
|
420
|
+
return [null, fallback, 0, null];
|
|
421
|
+
}
|
|
422
|
+
if (!looksLikeCID(cid)) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
`ESR entry for '${key}' holds a pointer that is not a CID (${cid.slice(0, 32)}…). ` +
|
|
425
|
+
'The committing code is writing a non-CID value.'
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
const blob = await this._fetch(key, cid);
|
|
429
|
+
const stored = JSON.parse(decrypt(blob).toString('utf8'));
|
|
430
|
+
const [acl, data] = unwrapStored(stored);
|
|
431
|
+
if (stored && typeof stored === 'object' && stored[CONTAINER_MARK] === 1
|
|
432
|
+
&& stored.nonce != null) {
|
|
433
|
+
// The blob carries a REPORTING COPY of the idempotency nonce; the chain
|
|
434
|
+
// is primary. Both were written by the same commit (the CID the chain
|
|
435
|
+
// points at IS this blob), so they must agree -- a mismatch means the
|
|
436
|
+
// registry entry and the state object have been tampered with or torn
|
|
437
|
+
// apart. Compare against the RAW chain value (not the task memo): the
|
|
438
|
+
// blob we fetched is the chain's cid.
|
|
439
|
+
const chainNonce = Number(await this._contract().getNonce(this.walletAddress, keyHash(key)));
|
|
440
|
+
const blobNonce = Math.trunc(Number(stored.nonce));
|
|
441
|
+
if (blobNonce !== chainNonce) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`ESR nonce mismatch for '${key}': the state object reports nonce ` +
|
|
444
|
+
`${blobNonce} but the registry records ${chainNonce} -- refusing ` +
|
|
445
|
+
'to use this state'
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return [acl, data, Number(version), cid];
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The last accepted idempotency nonce for `key` (0 if none was ever used).
|
|
454
|
+
* A dApp that wants duplicate suppression reads this and passes
|
|
455
|
+
* getNonce(key) + 1 to commit(..., { nonce }) -- the contract accepts
|
|
456
|
+
* EXACTLY the next value in sequence, nothing else.
|
|
457
|
+
*
|
|
458
|
+
* The nonce is PUBLIC data: the registry records it on-chain next to the
|
|
459
|
+
* version, and anyone can read it with a free eth_call (getNonce) -- so no
|
|
460
|
+
* read-ACL applies here, and web3 clients see the same value via the
|
|
461
|
+
* runner's esrNonce.
|
|
462
|
+
*
|
|
463
|
+
* Reads the chain (authoritative, post-relay), merged with this task's
|
|
464
|
+
* own accepted commits so `commit(..., { nonce: N }); getNonce()` returns N
|
|
465
|
+
* even before the node's relay lands.
|
|
466
|
+
*/
|
|
467
|
+
async getNonce(key) {
|
|
468
|
+
const chain = Number(await this._contract().getNonce(this.walletAddress, keyHash(key)));
|
|
469
|
+
return Math.max(chain || 0, Number(_taskNonces[key] || 0));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Read-modify-write `key` under optimistic concurrency.
|
|
474
|
+
*
|
|
475
|
+
* `mutate` receives the current state and returns the new state. If another
|
|
476
|
+
* commit lands in between, the contract rejects ours and we re-read and
|
|
477
|
+
* retry, so concurrent tasks cannot silently lose updates.
|
|
478
|
+
*/
|
|
479
|
+
async commit(key, mutate, opts = 3) {
|
|
480
|
+
// Back-compat: the third arg used to be `attempts` (a number); it may now
|
|
481
|
+
// also be { attempts, nonce }. The per-key nonce sequence advances by
|
|
482
|
+
// exactly 1 on EVERY commit (1, 2, 3, ... -- no gaps, no reuse; see
|
|
483
|
+
// getNonce).
|
|
484
|
+
// - Omitted: the SDK takes the next value in sequence automatically.
|
|
485
|
+
// Always succeeds (subject to permissions/versioning) but carries NO
|
|
486
|
+
// exactly-once guarantee -- a resubmitted task picks a fresh number.
|
|
487
|
+
// - Explicit: must be EXACTLY the last accepted nonce + 1, pinned by
|
|
488
|
+
// the CLIENT before submitting. Anything else throws StateNonceError
|
|
489
|
+
// (task code 36) and the state is NOT changed.
|
|
490
|
+
const { attempts = 3, nonce = null } =
|
|
491
|
+
typeof opts === 'number' ? { attempts: opts } : (opts || {});
|
|
492
|
+
const transform = async (acl, data) => {
|
|
493
|
+
if (!canWrite(acl)) {
|
|
494
|
+
throw new StatePermissionError(
|
|
495
|
+
`caller ${_taskCaller || '<anonymous>'} has no write permission on ` +
|
|
496
|
+
`state key '${key}' (owner: ${acl.owner})`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
let nextAcl = acl;
|
|
500
|
+
if (!nextAcl && _taskCaller) nextAcl = newAcl(_taskCaller); // first-writer-owns
|
|
501
|
+
return [nextAcl, await mutate(data)];
|
|
502
|
+
};
|
|
503
|
+
const [, newData] = await this._commitTransform(key, transform, attempts, nonce);
|
|
504
|
+
return newData;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Optimistic-concurrency commit of transform(acl, data, version) ->
|
|
509
|
+
* [newAcl, newData]. The stored blob is the wrapped container (or the bare
|
|
510
|
+
* data while unowned). On a version race we re-read and retry, so
|
|
511
|
+
* concurrent tasks cannot silently lose updates.
|
|
512
|
+
*/
|
|
513
|
+
async _commitTransform(key, transform, attempts = 3, nonce = null) {
|
|
514
|
+
if (_esrLedger.length >= MAX_COMMITS_PER_TASK) {
|
|
515
|
+
throw new StateLimitError(
|
|
516
|
+
`this task already made ${_esrLedger.length} state commits -- the ` +
|
|
517
|
+
`per-run limit is ${MAX_COMMITS_PER_TASK}. Batch your writes (one ` +
|
|
518
|
+
'commit can update a whole object) or split the work across tasks.'
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
let lastError = null;
|
|
522
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
523
|
+
// eslint-disable-next-line no-await-in-loop
|
|
524
|
+
const [acl, data, currentVersion] = await this._readContainer(key);
|
|
525
|
+
// The nonce check re-runs on every retry against the freshest visible
|
|
526
|
+
// nonce (chain merged with this task's own accepted commits), so a
|
|
527
|
+
// duplicate fails in-enclave with task code 36 instead of as a failed
|
|
528
|
+
// relay. The contract re-enforces the same rule on-chain either way.
|
|
529
|
+
let finalNonce;
|
|
530
|
+
if (nonce != null) {
|
|
531
|
+
finalNonce = Math.trunc(nonce);
|
|
532
|
+
if (finalNonce === 0) {
|
|
533
|
+
throw new StateNonceError(
|
|
534
|
+
`nonce=0 is reserved for 'not set' (the registry then assigns the ` +
|
|
535
|
+
`next value itself) -- omit the nonce on commit('${key}', ...) ` +
|
|
536
|
+
'instead of passing 0. Pinned nonces start at 1: use getNonce() + 1.'
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
// eslint-disable-next-line no-await-in-loop
|
|
540
|
+
const storedNonce = await this.getNonce(key);
|
|
541
|
+
if (finalNonce !== storedNonce + 1) {
|
|
542
|
+
throw new StateNonceError(
|
|
543
|
+
`nonce ${finalNonce} is out of sequence for state key '${key}' (last accepted: ` +
|
|
544
|
+
`${storedNonce}, expected: ${storedNonce + 1}); commit suppressed, ` +
|
|
545
|
+
'state unchanged'
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
// No explicit nonce: the CONTRACT assigns the next value in sequence
|
|
550
|
+
// itself (wire nonce 0 = "omitted"); this predicted value feeds the
|
|
551
|
+
// blob's reporting copy and the task memo. The sequence advances on
|
|
552
|
+
// EVERY commit; only an EXPLICIT nonce carries the exactly-once
|
|
553
|
+
// guarantee (an omitted one is auto-assigned a fresh number, so it
|
|
554
|
+
// cannot deduplicate -- by design).
|
|
555
|
+
// eslint-disable-next-line no-await-in-loop
|
|
556
|
+
finalNonce = (await this.getNonce(key)) + 1;
|
|
557
|
+
}
|
|
558
|
+
// eslint-disable-next-line no-await-in-loop
|
|
559
|
+
const [nextAcl, newData] = await transform(acl, data, currentVersion);
|
|
560
|
+
// The blob carries a REPORTING COPY of the nonce the registry will
|
|
561
|
+
// store for this key after this commit. Chain stays primary; reads
|
|
562
|
+
// verify the two match.
|
|
563
|
+
// Bind the authoring caller into the blob (hence the CID/signature) for
|
|
564
|
+
// owned state.
|
|
565
|
+
const stored = wrapStored(nextAcl, newData, nextAcl ? _taskCaller : null, finalNonce);
|
|
566
|
+
|
|
567
|
+
const blob = encrypt(Buffer.from(JSON.stringify(stored), 'utf8'));
|
|
568
|
+
// Compute the CID from OUR bytes — never accept one from the node.
|
|
569
|
+
const cid = cidv1Raw(blob);
|
|
570
|
+
|
|
571
|
+
// Hand the blob and the CID to the node for pinning. It verifies the CID
|
|
572
|
+
// matches; it does not get to choose it. Fire-and-forget.
|
|
573
|
+
// eslint-disable-next-line no-await-in-loop
|
|
574
|
+
await this._publish(key, blob, cid);
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
// Wire value: the pinned nonce, or 0 = "omitted" -- the contract
|
|
578
|
+
// then assigns the next value atomically on-chain.
|
|
579
|
+
// eslint-disable-next-line no-await-in-loop
|
|
580
|
+
await this._sendCommit(keyHash(key), cid, currentVersion,
|
|
581
|
+
nextAcl ? normAddr(nextAcl.owner) : null,
|
|
582
|
+
nonce != null ? Math.trunc(nonce) : 0);
|
|
583
|
+
// Record the POST-commit values (the DATA -- the ACL never leaves in
|
|
584
|
+
// results): this is what the chain shows once the relay lands.
|
|
585
|
+
ledgerRecord(key, currentVersion + 1, cid, newData);
|
|
586
|
+
_taskNonces[key] = finalNonce;
|
|
587
|
+
return [nextAcl, newData];
|
|
588
|
+
} catch (e) {
|
|
589
|
+
const msg = String(e && e.message ? e.message : e);
|
|
590
|
+
if (/VersionMismatch/i.test(msg) || /version/i.test(msg)) {
|
|
591
|
+
lastError = e;
|
|
592
|
+
// eslint-disable-next-line no-continue
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
throw e;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
throw new Error(`ESR commit failed after ${attempts} attempts: ${lastError}`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async _publish(key, blob, cid) {
|
|
602
|
+
await _swift.putFileContent(_bucket, `state.${key}.enc`, '', blob);
|
|
603
|
+
await _swift.putFileContent(_bucket, `state.${key}.cid`, '', cid);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Read a state object back, verifying it against the on-chain CID.
|
|
608
|
+
*
|
|
609
|
+
* The enclave cannot reach IPFS (the Kubo API binds loopback on the host), so
|
|
610
|
+
* the node stages state objects in the bucket. That makes the node the
|
|
611
|
+
* delivery path but not a trusted one: content is checked against the CID the
|
|
612
|
+
* chain records, so a substituted blob is rejected rather than decrypted.
|
|
613
|
+
*/
|
|
614
|
+
async _fetch(key, cid) {
|
|
615
|
+
for (const name of [`state.${key}.enc`, `state.${cid}.enc`]) {
|
|
616
|
+
// eslint-disable-next-line no-await-in-loop
|
|
617
|
+
const data = await _swift.getFileContentBytes(_bucket, name).catch(() => null);
|
|
618
|
+
if (data && data.length) {
|
|
619
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
620
|
+
if (cidv1Raw(buf) !== cid) {
|
|
621
|
+
throw new Error(
|
|
622
|
+
`State object ${name} does not match the committed CID ${cid} — refusing to use it`
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
return buf;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
throw new Error(`Could not read state object for '${key}' (${cid})`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async _sendCommit(keyHashHex, cid, expectedVersion, ownerAddr = null, nonce = 0) {
|
|
632
|
+
// The enclave never pays gas and does NO gas math: it SIGNS the commit
|
|
633
|
+
// (commitFor) and stages the signed authorization for the node, which
|
|
634
|
+
// relays it and pays. The trustedzone independently re-prices the whole
|
|
635
|
+
// ledger and adjudicates. Nothing the securelock could claim about cost is
|
|
636
|
+
// trusted, so it claims nothing.
|
|
637
|
+
const signer = this._signer();
|
|
638
|
+
const enclave = await signer.getAddress();
|
|
639
|
+
const contract = this._contract();
|
|
640
|
+
|
|
641
|
+
// Relay nonce is PER (enclave, key): same-key commits are strictly
|
|
642
|
+
// serialized network-wide, different keys relay in parallel. Merge the
|
|
643
|
+
// chain value with this task's own staged commits so a second commit to
|
|
644
|
+
// the same key in ONE task signs the next number even before (or while)
|
|
645
|
+
// the node relays the first.
|
|
646
|
+
const khHex = String(keyHashHex).toLowerCase();
|
|
647
|
+
const chainRn = Number(await contract.relayNonce(enclave, keyHashHex));
|
|
648
|
+
const relayNonce = Math.max(chainRn, Number(_taskRelayNonces[khHex] || 0));
|
|
649
|
+
// The idempotency nonce is signature-bound: it is part of the digest the
|
|
650
|
+
// enclave signs, so the relaying node cannot alter or strip it.
|
|
651
|
+
const digest = await contract.commitDigest(
|
|
652
|
+
enclave, keyHashHex, cid, expectedVersion, relayNonce, Math.trunc(nonce || 0));
|
|
653
|
+
// Sign the raw 32-byte digest as an eth_sign message (matches the
|
|
654
|
+
// contract's "\x19Ethereum Signed Message:\n32" recovery).
|
|
655
|
+
const signature = await signer.signMessage(ethers.utils.arrayify(digest));
|
|
656
|
+
|
|
657
|
+
const auth = {
|
|
658
|
+
enclave,
|
|
659
|
+
keyHash: keyHashHex,
|
|
660
|
+
cid,
|
|
661
|
+
expectedVersion: Number(expectedVersion),
|
|
662
|
+
relayNonce: Number(relayNonce),
|
|
663
|
+
// PUBLIC idempotency nonce (0 = no guard): recorded on-chain by
|
|
664
|
+
// commitFor, enforced strictly increasing per (enclave, key).
|
|
665
|
+
nonce: Math.trunc(nonce || 0),
|
|
666
|
+
signature,
|
|
667
|
+
// IMPERSONATION GUARD: the trustedzone requires callerUsed to equal the
|
|
668
|
+
// DO owner it reads from the PoX contract, so a payload that forged the
|
|
669
|
+
// in-enclave caller to impersonate another user is rejected outside the
|
|
670
|
+
// payload's process. It does NOT verify the caller was a permitted
|
|
671
|
+
// writer (it cannot read the encrypted ACL). Unowned commits carry no
|
|
672
|
+
// constraint.
|
|
673
|
+
callerUsed: _taskCaller || '',
|
|
674
|
+
owned: Boolean(ownerAddr)
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
_esrLedger.push(auth);
|
|
678
|
+
await _swift.putFileContent(
|
|
679
|
+
_bucket, 'esr.authorizations.json', '',
|
|
680
|
+
Buffer.from(JSON.stringify(_esrLedger), 'utf8'));
|
|
681
|
+
// The staged filename carries the key prefix so commits to different keys
|
|
682
|
+
// within one task never collide, and the per-key relay order is explicit.
|
|
683
|
+
const kh16 = khHex.replace(/^0x/, '').slice(0, 16);
|
|
684
|
+
await _swift.putFileContent(
|
|
685
|
+
_bucket, `esr.commit.${kh16}.${Number(relayNonce)}.json`, '',
|
|
686
|
+
Buffer.from(JSON.stringify(auth), 'utf8'));
|
|
687
|
+
_taskRelayNonces[khHex] = Number(relayNonce) + 1;
|
|
688
|
+
|
|
689
|
+
// LOCAL TESTING: with no node to relay commitFor, apply the commit to the
|
|
690
|
+
// in-memory registry now so getState/getVersion reflect it. No-op in the
|
|
691
|
+
// real enclave (hook is null) -- there the node applies it on-chain.
|
|
692
|
+
if (_localCommitApply) {
|
|
693
|
+
await _localCommitApply(enclave, keyHashHex, cid, Number(expectedVersion),
|
|
694
|
+
Math.trunc(nonce || 0));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return { relayed: true, relayNonce: Number(relayNonce) };
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Owner-only ACL mutation; an unowned key is claimed first (owner = caller). */
|
|
702
|
+
async function ownerOp(key, mutator) {
|
|
703
|
+
if (!_taskCaller) {
|
|
704
|
+
throw new StatePermissionError(
|
|
705
|
+
'state management requires an authenticated caller (the trustedzone supplied none)'
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
const reg = new StateRegistry();
|
|
709
|
+
const transform = async (acl, data) => {
|
|
710
|
+
let nextAcl = acl || newAcl(_taskCaller);
|
|
711
|
+
if (normAddr(nextAcl.owner) !== _taskCaller) {
|
|
712
|
+
throw new StatePermissionError(
|
|
713
|
+
`caller ${_taskCaller} is not the owner of state key '${key}' (owner: ${nextAcl.owner})`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
nextAcl = mutator({ ...nextAcl });
|
|
717
|
+
return [nextAcl, data];
|
|
718
|
+
};
|
|
719
|
+
const [acl] = await reg._commitTransform(key, transform);
|
|
720
|
+
return { ...acl };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function assertLevel(level) {
|
|
724
|
+
if (level !== 'read' && level !== 'write') throw new Error("level must be 'read' or 'write'");
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function esrGrant(key, address, level = 'read') {
|
|
728
|
+
assertLevel(level);
|
|
729
|
+
const addr = normAddr(address);
|
|
730
|
+
if (!addr) throw new Error('a grantee address is required');
|
|
731
|
+
return ownerOp(key, (acl) => {
|
|
732
|
+
const members = new Set((acl[level] || []).map(normAddr).filter(Boolean));
|
|
733
|
+
members.add(addr);
|
|
734
|
+
acl[level] = [...members].sort();
|
|
735
|
+
return acl;
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async function esrRevoke(key, address, level = 'read') {
|
|
740
|
+
assertLevel(level);
|
|
741
|
+
const addr = normAddr(address);
|
|
742
|
+
return ownerOp(key, (acl) => {
|
|
743
|
+
acl[level] = (acl[level] || []).map(normAddr).filter((a) => a && a !== addr).sort();
|
|
744
|
+
return acl;
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function esrSetPublicRead(key, enabled = true) {
|
|
749
|
+
return ownerOp(key, (acl) => {
|
|
750
|
+
acl.public_read = Boolean(enabled);
|
|
751
|
+
return acl;
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function esrTransfer(key, newOwner) {
|
|
756
|
+
const addr = normAddr(newOwner);
|
|
757
|
+
if (!addr) throw new Error('a new owner address is required');
|
|
758
|
+
return ownerOp(key, (acl) => {
|
|
759
|
+
acl.owner = addr;
|
|
760
|
+
return acl;
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Owner address of `key`, or null while unowned. Exposes nothing else. */
|
|
765
|
+
async function esrOwner(key) {
|
|
766
|
+
const [acl] = await new StateRegistry()._readContainer(key);
|
|
767
|
+
return acl ? acl.owner : null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Owner-only: the full ACL of `key`. */
|
|
771
|
+
async function esrAcl(key) {
|
|
772
|
+
const [acl] = await new StateRegistry()._readContainer(key);
|
|
773
|
+
if (!acl) return null;
|
|
774
|
+
if (!_taskCaller || normAddr(acl.owner) !== _taskCaller) {
|
|
775
|
+
throw new StatePermissionError(
|
|
776
|
+
`caller ${_taskCaller || '<anonymous>'} is not the owner of state key '${key}'`
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
return { ...acl };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function setTaskCaller(caller) { _taskCaller = normAddr(caller); }
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* The last accepted idempotency nonce for `key` (0 if none). See
|
|
786
|
+
* StateRegistry.getNonce / commit(key, mutate, { nonce }).
|
|
787
|
+
*/
|
|
788
|
+
async function esrNonce(key) {
|
|
789
|
+
return new StateRegistry().getNonce(key);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
module.exports = {
|
|
793
|
+
StateRegistry,
|
|
794
|
+
configure,
|
|
795
|
+
cidv1Raw,
|
|
796
|
+
looksLikeCID,
|
|
797
|
+
ledgerSnapshot,
|
|
798
|
+
taskCaller,
|
|
799
|
+
setTaskCaller,
|
|
800
|
+
restampLedgerCaller,
|
|
801
|
+
StatePermissionError,
|
|
802
|
+
StateNonceError,
|
|
803
|
+
StateLimitError,
|
|
804
|
+
esrGrant,
|
|
805
|
+
esrRevoke,
|
|
806
|
+
esrSetPublicRead,
|
|
807
|
+
esrTransfer,
|
|
808
|
+
esrOwner,
|
|
809
|
+
esrAcl,
|
|
810
|
+
esrNonce,
|
|
811
|
+
// local-testing hooks (esr_local only)
|
|
812
|
+
setContractOverride,
|
|
813
|
+
setLocalCommitApply,
|
|
814
|
+
normAddr
|
|
815
|
+
};
|