@ethernity-cloud/sdk-js 1.5.4 → 1.5.8
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/nodenithy/build/securelock/src/ecld_state.js +286 -12
- package/nodenithy/build/securelock/src/etny_exec.js +103 -3
- package/nodenithy/build/securelock/src/securelock.js.tmpl +47 -1
- package/nodenithy/build/securelock/src/task_status.js +4 -0
- package/nodenithy/build.mjs +30 -0
- package/package.json +51 -50
|
@@ -54,13 +54,143 @@ let _provider = null;
|
|
|
54
54
|
* Wire the registry to the enclave's identity, storage and chain access.
|
|
55
55
|
* Called by securelock at task start; payload code never calls this.
|
|
56
56
|
*/
|
|
57
|
-
function configure({ identityPriv, swiftStreamService, bucket, contractAddress, provider }) {
|
|
57
|
+
function configure({ identityPriv, swiftStreamService, bucket, contractAddress, provider, caller = null }) {
|
|
58
58
|
_identityPriv = identityPriv;
|
|
59
59
|
_swift = swiftStreamService;
|
|
60
60
|
_bucket = bucket;
|
|
61
61
|
_contractAddress = contractAddress;
|
|
62
62
|
_provider = provider;
|
|
63
63
|
_esrLedger = [];
|
|
64
|
+
_taskLedger = {};
|
|
65
|
+
_taskCaller = normAddr(caller);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The authenticated task caller: the wallet that placed the DO request, read
|
|
69
|
+
// from the PoX contract by the TRUSTEDZONE and forwarded over its signed
|
|
70
|
+
// handoff (caller.securelock + .sig, verified by securelock before configure).
|
|
71
|
+
// null when the trustedzone did not supply one -- anonymity is never a
|
|
72
|
+
// privilege: null cannot claim ownership or touch owned state.
|
|
73
|
+
let _taskCaller = null;
|
|
74
|
+
|
|
75
|
+
function taskCaller() {
|
|
76
|
+
return _taskCaller;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normAddr(addr) {
|
|
80
|
+
return typeof addr === 'string' && addr ? addr.toLowerCase() : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* State container + ACL. Stored blobs are either legacy (raw state --
|
|
85
|
+
* "unowned") or an owned container:
|
|
86
|
+
* { _ecld_state: 1, acl: {...}, data: <state> }
|
|
87
|
+
* acl = { owner, read: [], write: [], public_read: false }. Enforcement
|
|
88
|
+
* happens HERE, inside the enclave, against the trustedzone-attested caller.
|
|
89
|
+
*/
|
|
90
|
+
const CONTAINER_MARK = '_ecld_state';
|
|
91
|
+
|
|
92
|
+
class StatePermissionError extends Error {}
|
|
93
|
+
|
|
94
|
+
function unwrapStored(stored) {
|
|
95
|
+
if (stored && typeof stored === 'object' && stored[CONTAINER_MARK] === 1) {
|
|
96
|
+
return [stored.acl || null, stored.data];
|
|
97
|
+
}
|
|
98
|
+
return [null, stored];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function wrapStored(acl, data, committedBy = null) {
|
|
102
|
+
// For owned state, bind the authoring caller (committedBy) INTO the blob, so
|
|
103
|
+
// it lands in the CID -- the field the securelock signs -- making the caller
|
|
104
|
+
// cryptographically bound to the commit, not merely a re-stampable sidecar.
|
|
105
|
+
if (!acl) return data; // unowned keeps the legacy shape
|
|
106
|
+
const container = { [CONTAINER_MARK]: 1, acl, data };
|
|
107
|
+
if (committedBy) container.committedBy = committedBy;
|
|
108
|
+
return container;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Post-payload: re-assert the trusted caller across the ESR ledger. Called by
|
|
113
|
+
* the securelock after the payload returns, with the attested caller captured
|
|
114
|
+
* before execution (outside the payload's reach). Overwrites callerUsed (and
|
|
115
|
+
* re-fixes enclave to the real signer) in every owned entry and RE-STAGES the
|
|
116
|
+
* ledger, so a forged callerUsed cannot reach the trustedzone. Returns true if
|
|
117
|
+
* any entry's callerUsed differed from the trusted value (tamper detected).
|
|
118
|
+
* Defence in depth -- the trustedzone and the CID-bound caller are the sound
|
|
119
|
+
* layers; this runs in the same process the payload subverted.
|
|
120
|
+
*/
|
|
121
|
+
async function restampLedgerCaller(trustedCaller) {
|
|
122
|
+
const trusted = normAddr(trustedCaller);
|
|
123
|
+
if (!_esrLedger.length) return false;
|
|
124
|
+
let tampered = false;
|
|
125
|
+
let signerAddr = null;
|
|
126
|
+
try {
|
|
127
|
+
signerAddr = await new StateRegistry()._signer().getAddress();
|
|
128
|
+
} catch (e) { signerAddr = null; }
|
|
129
|
+
for (const entry of _esrLedger) {
|
|
130
|
+
if (!entry.owned) continue;
|
|
131
|
+
if (normAddr(entry.callerUsed) !== trusted) tampered = true;
|
|
132
|
+
entry.callerUsed = trusted || '';
|
|
133
|
+
if (signerAddr) entry.enclave = signerAddr;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
await _swift.putFileContent(
|
|
137
|
+
_bucket, 'esr.authorizations.json', '',
|
|
138
|
+
Buffer.from(JSON.stringify(_esrLedger), 'utf8'));
|
|
139
|
+
} catch (e) { /* best-effort restage; trustedzone still adjudicates */ }
|
|
140
|
+
return tampered;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function aclMembers(acl, field) {
|
|
144
|
+
return new Set((acl[field] || []).map(normAddr).filter(Boolean));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function canRead(acl) {
|
|
148
|
+
if (!acl || acl.public_read) return true;
|
|
149
|
+
const c = _taskCaller;
|
|
150
|
+
if (!c) return false;
|
|
151
|
+
return c === normAddr(acl.owner) || aclMembers(acl, 'read').has(c) || aclMembers(acl, 'write').has(c);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function canWrite(acl) {
|
|
155
|
+
if (!acl) return true; // unowned: writable; claimed when a caller exists
|
|
156
|
+
const c = _taskCaller;
|
|
157
|
+
if (!c) return false;
|
|
158
|
+
return c === normAddr(acl.owner) || aclMembers(acl, 'write').has(c);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function newAcl(owner) {
|
|
162
|
+
return { owner, read: [], write: [], public_read: false };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Task-scoped ledger of every state key this execution touched, recorded by
|
|
166
|
+
// get()/commit(). ecldResult() snapshots it to attach fresh state to the task
|
|
167
|
+
// result, so callers (and the runner's state cache) get current state in the
|
|
168
|
+
// same result -- no separate read task needed. Reset per task in configure().
|
|
169
|
+
let _taskLedger = {};
|
|
170
|
+
|
|
171
|
+
function ledgerRecord(key, version, cid, state) {
|
|
172
|
+
_taskLedger[key] = { key, version: Number(version), cid, state };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The `esr` attachment for ecldResult: wallet + entries. Synchronous by
|
|
177
|
+
* design (the securelock consumes results synchronously): entries come from
|
|
178
|
+
* the in-memory ledger of keys this task already touched. To attach a key the
|
|
179
|
+
* task did not touch, use the async esrFetch() helper, which awaits the reads
|
|
180
|
+
* (populating the ledger) before building the result.
|
|
181
|
+
*/
|
|
182
|
+
function ledgerSnapshot(includeState = true, keys = null) {
|
|
183
|
+
const reg = new StateRegistry();
|
|
184
|
+
const wanted = keys ? keys : Object.keys(_taskLedger);
|
|
185
|
+
const entries = [];
|
|
186
|
+
for (const k of wanted) {
|
|
187
|
+
const e = _taskLedger[k];
|
|
188
|
+
if (!e) continue;
|
|
189
|
+
const entry = { ...e };
|
|
190
|
+
if (!includeState) delete entry.state;
|
|
191
|
+
entries.push(entry);
|
|
192
|
+
}
|
|
193
|
+
return { wallet: reg.walletAddress, entries };
|
|
64
194
|
}
|
|
65
195
|
|
|
66
196
|
function keccak256(buf) {
|
|
@@ -215,8 +345,23 @@ class StateRegistry {
|
|
|
215
345
|
* state" would let the next commit overwrite data that is still recoverable.
|
|
216
346
|
*/
|
|
217
347
|
async get(key, fallback = {}) {
|
|
348
|
+
const [acl, data, version, cid] = await this._readContainer(key, fallback);
|
|
349
|
+
if (!canRead(acl)) {
|
|
350
|
+
throw new StatePermissionError(
|
|
351
|
+
`caller ${_taskCaller || '<anonymous>'} has no read permission on ` +
|
|
352
|
+
`state key '${key}' (owner: ${acl.owner})`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
ledgerRecord(key, version || 0, cid, data);
|
|
356
|
+
return data;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** [acl, data, version, cid] for `key`; NO permission check here. */
|
|
360
|
+
async _readContainer(key, fallback = {}) {
|
|
218
361
|
const [cid, version] = await this._contract().getState(this.walletAddress, keyHash(key));
|
|
219
|
-
if (!version || !cid)
|
|
362
|
+
if (!version || !cid) {
|
|
363
|
+
return [null, fallback, 0, null];
|
|
364
|
+
}
|
|
220
365
|
if (!looksLikeCID(cid)) {
|
|
221
366
|
throw new Error(
|
|
222
367
|
`ESR entry for '${key}' holds a pointer that is not a CID (${cid.slice(0, 32)}…). ` +
|
|
@@ -224,7 +369,9 @@ class StateRegistry {
|
|
|
224
369
|
);
|
|
225
370
|
}
|
|
226
371
|
const blob = await this._fetch(key, cid);
|
|
227
|
-
|
|
372
|
+
const stored = JSON.parse(decrypt(blob).toString('utf8'));
|
|
373
|
+
const [acl, data] = unwrapStored(stored);
|
|
374
|
+
return [acl, data, Number(version), cid];
|
|
228
375
|
}
|
|
229
376
|
|
|
230
377
|
/**
|
|
@@ -235,15 +382,39 @@ class StateRegistry {
|
|
|
235
382
|
* retry, so concurrent tasks cannot silently lose updates.
|
|
236
383
|
*/
|
|
237
384
|
async commit(key, mutate, attempts = 3) {
|
|
385
|
+
const transform = async (acl, data) => {
|
|
386
|
+
if (!canWrite(acl)) {
|
|
387
|
+
throw new StatePermissionError(
|
|
388
|
+
`caller ${_taskCaller || '<anonymous>'} has no write permission on ` +
|
|
389
|
+
`state key '${key}' (owner: ${acl.owner})`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
let nextAcl = acl;
|
|
393
|
+
if (!nextAcl && _taskCaller) nextAcl = newAcl(_taskCaller); // first-writer-owns
|
|
394
|
+
return [nextAcl, await mutate(data)];
|
|
395
|
+
};
|
|
396
|
+
const [, newData] = await this._commitTransform(key, transform, attempts);
|
|
397
|
+
return newData;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Optimistic-concurrency commit of transform(acl, data, version) ->
|
|
402
|
+
* [newAcl, newData]. The stored blob is the wrapped container (or the bare
|
|
403
|
+
* data while unowned). On a version race we re-read and retry, so
|
|
404
|
+
* concurrent tasks cannot silently lose updates.
|
|
405
|
+
*/
|
|
406
|
+
async _commitTransform(key, transform, attempts = 3) {
|
|
238
407
|
let lastError = null;
|
|
239
408
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
240
409
|
// eslint-disable-next-line no-await-in-loop
|
|
241
|
-
const currentVersion = await this.
|
|
410
|
+
const [acl, data, currentVersion] = await this._readContainer(key);
|
|
242
411
|
// eslint-disable-next-line no-await-in-loop
|
|
243
|
-
const
|
|
244
|
-
|
|
412
|
+
const [nextAcl, newData] = await transform(acl, data, currentVersion);
|
|
413
|
+
// Bind the authoring caller into the blob (hence the CID/signature) for
|
|
414
|
+
// owned state.
|
|
415
|
+
const stored = wrapStored(nextAcl, newData, nextAcl ? _taskCaller : null);
|
|
245
416
|
|
|
246
|
-
const blob = encrypt(Buffer.from(JSON.stringify(
|
|
417
|
+
const blob = encrypt(Buffer.from(JSON.stringify(stored), 'utf8'));
|
|
247
418
|
// Compute the CID from OUR bytes — never accept one from the node.
|
|
248
419
|
const cid = cidv1Raw(blob);
|
|
249
420
|
|
|
@@ -254,8 +425,12 @@ class StateRegistry {
|
|
|
254
425
|
|
|
255
426
|
try {
|
|
256
427
|
// eslint-disable-next-line no-await-in-loop
|
|
257
|
-
await this._sendCommit(keyHash(key), cid, currentVersion
|
|
258
|
-
|
|
428
|
+
await this._sendCommit(keyHash(key), cid, currentVersion,
|
|
429
|
+
nextAcl ? normAddr(nextAcl.owner) : null);
|
|
430
|
+
// Record the POST-commit values (the DATA -- the ACL never leaves in
|
|
431
|
+
// results): this is what the chain shows once the relay lands.
|
|
432
|
+
ledgerRecord(key, currentVersion + 1, cid, newData);
|
|
433
|
+
return [nextAcl, newData];
|
|
259
434
|
} catch (e) {
|
|
260
435
|
const msg = String(e && e.message ? e.message : e);
|
|
261
436
|
if (/VersionMismatch/i.test(msg) || /version/i.test(msg)) {
|
|
@@ -299,7 +474,7 @@ class StateRegistry {
|
|
|
299
474
|
throw new Error(`Could not read state object for '${key}' (${cid})`);
|
|
300
475
|
}
|
|
301
476
|
|
|
302
|
-
async _sendCommit(keyHashHex, cid, expectedVersion) {
|
|
477
|
+
async _sendCommit(keyHashHex, cid, expectedVersion, ownerAddr = null) {
|
|
303
478
|
// The enclave never pays gas and does NO gas math: it SIGNS the commit
|
|
304
479
|
// (commitFor) and stages the signed authorization for the node, which
|
|
305
480
|
// relays it and pays. The trustedzone independently re-prices the whole
|
|
@@ -322,7 +497,15 @@ class StateRegistry {
|
|
|
322
497
|
cid,
|
|
323
498
|
expectedVersion: Number(expectedVersion),
|
|
324
499
|
relayNonce: Number(relayNonce),
|
|
325
|
-
signature
|
|
500
|
+
signature,
|
|
501
|
+
// IMPERSONATION GUARD: the trustedzone requires callerUsed to equal the
|
|
502
|
+
// DO owner it reads from the PoX contract, so a payload that forged the
|
|
503
|
+
// in-enclave caller to impersonate another user is rejected outside the
|
|
504
|
+
// payload's process. It does NOT verify the caller was a permitted
|
|
505
|
+
// writer (it cannot read the encrypted ACL). Unowned commits carry no
|
|
506
|
+
// constraint.
|
|
507
|
+
callerUsed: _taskCaller || '',
|
|
508
|
+
owned: Boolean(ownerAddr)
|
|
326
509
|
};
|
|
327
510
|
|
|
328
511
|
_esrLedger.push(auth);
|
|
@@ -336,9 +519,100 @@ class StateRegistry {
|
|
|
336
519
|
}
|
|
337
520
|
}
|
|
338
521
|
|
|
522
|
+
/** Owner-only ACL mutation; an unowned key is claimed first (owner = caller). */
|
|
523
|
+
async function ownerOp(key, mutator) {
|
|
524
|
+
if (!_taskCaller) {
|
|
525
|
+
throw new StatePermissionError(
|
|
526
|
+
'state management requires an authenticated caller (the trustedzone supplied none)'
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
const reg = new StateRegistry();
|
|
530
|
+
const transform = async (acl, data) => {
|
|
531
|
+
let nextAcl = acl || newAcl(_taskCaller);
|
|
532
|
+
if (normAddr(nextAcl.owner) !== _taskCaller) {
|
|
533
|
+
throw new StatePermissionError(
|
|
534
|
+
`caller ${_taskCaller} is not the owner of state key '${key}' (owner: ${nextAcl.owner})`
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
nextAcl = mutator({ ...nextAcl });
|
|
538
|
+
return [nextAcl, data];
|
|
539
|
+
};
|
|
540
|
+
const [acl] = await reg._commitTransform(key, transform);
|
|
541
|
+
return { ...acl };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function assertLevel(level) {
|
|
545
|
+
if (level !== 'read' && level !== 'write') throw new Error("level must be 'read' or 'write'");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function esrGrant(key, address, level = 'read') {
|
|
549
|
+
assertLevel(level);
|
|
550
|
+
const addr = normAddr(address);
|
|
551
|
+
if (!addr) throw new Error('a grantee address is required');
|
|
552
|
+
return ownerOp(key, (acl) => {
|
|
553
|
+
const members = new Set((acl[level] || []).map(normAddr).filter(Boolean));
|
|
554
|
+
members.add(addr);
|
|
555
|
+
acl[level] = [...members].sort();
|
|
556
|
+
return acl;
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function esrRevoke(key, address, level = 'read') {
|
|
561
|
+
assertLevel(level);
|
|
562
|
+
const addr = normAddr(address);
|
|
563
|
+
return ownerOp(key, (acl) => {
|
|
564
|
+
acl[level] = (acl[level] || []).map(normAddr).filter((a) => a && a !== addr).sort();
|
|
565
|
+
return acl;
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function esrSetPublicRead(key, enabled = true) {
|
|
570
|
+
return ownerOp(key, (acl) => {
|
|
571
|
+
acl.public_read = Boolean(enabled);
|
|
572
|
+
return acl;
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function esrTransfer(key, newOwner) {
|
|
577
|
+
const addr = normAddr(newOwner);
|
|
578
|
+
if (!addr) throw new Error('a new owner address is required');
|
|
579
|
+
return ownerOp(key, (acl) => {
|
|
580
|
+
acl.owner = addr;
|
|
581
|
+
return acl;
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Owner address of `key`, or null while unowned. Exposes nothing else. */
|
|
586
|
+
async function esrOwner(key) {
|
|
587
|
+
const [acl] = await new StateRegistry()._readContainer(key);
|
|
588
|
+
return acl ? acl.owner : null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Owner-only: the full ACL of `key`. */
|
|
592
|
+
async function esrAcl(key) {
|
|
593
|
+
const [acl] = await new StateRegistry()._readContainer(key);
|
|
594
|
+
if (!acl) return null;
|
|
595
|
+
if (!_taskCaller || normAddr(acl.owner) !== _taskCaller) {
|
|
596
|
+
throw new StatePermissionError(
|
|
597
|
+
`caller ${_taskCaller || '<anonymous>'} is not the owner of state key '${key}'`
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
return { ...acl };
|
|
601
|
+
}
|
|
602
|
+
|
|
339
603
|
module.exports = {
|
|
340
604
|
StateRegistry,
|
|
341
605
|
configure,
|
|
342
606
|
cidv1Raw,
|
|
343
|
-
looksLikeCID
|
|
607
|
+
looksLikeCID,
|
|
608
|
+
ledgerSnapshot,
|
|
609
|
+
taskCaller,
|
|
610
|
+
restampLedgerCaller,
|
|
611
|
+
StatePermissionError,
|
|
612
|
+
esrGrant,
|
|
613
|
+
esrRevoke,
|
|
614
|
+
esrSetPublicRead,
|
|
615
|
+
esrTransfer,
|
|
616
|
+
esrOwner,
|
|
617
|
+
esrAcl
|
|
344
618
|
};
|
|
@@ -30,8 +30,84 @@ try {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Automatic encoding for task results (single code path for all results):
|
|
35
|
+
* JSON-serializable values ride as JSON, strings as text, bytes-like as
|
|
36
|
+
* base64. Anything else is JSON-stringified with a String() fallback -- a
|
|
37
|
+
* task result never fails on encoding.
|
|
38
|
+
*/
|
|
39
|
+
function encodeResultData(data) {
|
|
40
|
+
if (Buffer.isBuffer(data) || data instanceof Uint8Array) {
|
|
41
|
+
return ['base64', Buffer.from(data).toString('base64')];
|
|
42
|
+
}
|
|
43
|
+
if (typeof data === 'string') {
|
|
44
|
+
return ['text', data];
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
JSON.stringify(data);
|
|
48
|
+
return ['json', data === undefined ? null : data];
|
|
49
|
+
} catch (e) {
|
|
50
|
+
try {
|
|
51
|
+
return ['json', JSON.parse(JSON.stringify(data, (k, v) =>
|
|
52
|
+
typeof v === 'bigint' || typeof v === 'function' ? String(v) : v))];
|
|
53
|
+
} catch (e2) {
|
|
54
|
+
return ['text', String(data)];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Return a task result (THE way to end a task).
|
|
61
|
+
*
|
|
62
|
+
* Builds the structured result envelope:
|
|
63
|
+
* { ecld: 1, type: 'json'|'text'|'base64', data: ..., esr: ... }
|
|
64
|
+
*
|
|
65
|
+
* - `data` is encoded automatically (see encodeResultData).
|
|
66
|
+
* - `state: true` (default) attaches the ESR state of every key this task
|
|
67
|
+
* touched; `state: 'meta'` attaches {key, version, cid} without the state
|
|
68
|
+
* payload; `state: false` attaches nothing.
|
|
69
|
+
* - `keys: [...]` restricts the attachment to those keys (already-touched
|
|
70
|
+
* only -- to attach an untouched key, use the async esrFetch helper).
|
|
71
|
+
*
|
|
72
|
+
* ESR attachment is best-effort: an ESR-disabled build (or any attachment
|
|
73
|
+
* error) yields esr: null, never a failed task.
|
|
74
|
+
*/
|
|
75
|
+
function ecldResult(data = null, { state = true, keys = null } = {}) {
|
|
76
|
+
const [type, encoded] = encodeResultData(data);
|
|
77
|
+
const envelope = { ecld: 1, type, data: encoded, esr: null };
|
|
78
|
+
if (state || keys) {
|
|
79
|
+
try {
|
|
80
|
+
// eslint-disable-next-line global-require
|
|
81
|
+
const ecldState = require('./ecld_state');
|
|
82
|
+
envelope.esr = ecldState.ledgerSnapshot(state === true, keys);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
envelope.esr = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [0, JSON.stringify(envelope)];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Legacy alias: same builder, ESR attachment off -- today's behavior. */
|
|
33
91
|
function ___etny_result___(data) {
|
|
34
|
-
return
|
|
92
|
+
return ecldResult(data, { state: false });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Standard state-fetch task body: awaits the reads (populating the task
|
|
97
|
+
* ledger), then attaches them. The runner's cache-gated read submits this on
|
|
98
|
+
* a cache miss: `await esrFetch('profile-7')`.
|
|
99
|
+
*/
|
|
100
|
+
async function esrFetch(...keys) {
|
|
101
|
+
try {
|
|
102
|
+
// eslint-disable-next-line global-require
|
|
103
|
+
const { StateRegistry } = require('./ecld_state');
|
|
104
|
+
const reg = new StateRegistry();
|
|
105
|
+
for (const k of keys) {
|
|
106
|
+
// eslint-disable-next-line no-await-in-loop
|
|
107
|
+
await reg.get(k);
|
|
108
|
+
}
|
|
109
|
+
} catch (e) { /* ESR-disabled build: esr stays null */ }
|
|
110
|
+
return ecldResult(null, { keys: keys.length ? keys : null });
|
|
35
111
|
}
|
|
36
112
|
|
|
37
113
|
// Names of *_ADDRESS-style enclave config vars that are set but EMPTY.
|
|
@@ -66,7 +142,23 @@ function executeTask(payload, input) {
|
|
|
66
142
|
' image dependencies, or a bad require() inside backend.js.'
|
|
67
143
|
];
|
|
68
144
|
}
|
|
69
|
-
|
|
145
|
+
const scope = {
|
|
146
|
+
'___etny_result___': ___etny_result___, // legacy alias (no ESR)
|
|
147
|
+
'ecldResult': ecldResult, // the result API
|
|
148
|
+
'esrFetch': esrFetch, // standard state-fetch task
|
|
149
|
+
...backendFunctions,
|
|
150
|
+
};
|
|
151
|
+
// State ownership / ACL API (present only in ESR-enabled builds). Enforced
|
|
152
|
+
// inside ecld_state against the trustedzone-attested caller.
|
|
153
|
+
try {
|
|
154
|
+
// eslint-disable-next-line global-require
|
|
155
|
+
const ecldState = require('./ecld_state');
|
|
156
|
+
for (const name of ['taskCaller', 'esrGrant', 'esrRevoke',
|
|
157
|
+
'esrSetPublicRead', 'esrTransfer', 'esrOwner', 'esrAcl']) {
|
|
158
|
+
if (!(name in scope)) scope[name] = ecldState[name];
|
|
159
|
+
}
|
|
160
|
+
} catch (e) { /* non-ESR build */ }
|
|
161
|
+
return exec(payload, input, scope);
|
|
70
162
|
}
|
|
71
163
|
|
|
72
164
|
function exec(payload, input, globals = null) {
|
|
@@ -80,7 +172,15 @@ function exec(payload, input, globals = null) {
|
|
|
80
172
|
// the payload by bare name — matching how payloads are written
|
|
81
173
|
// (e.g. `processData(___etny_data_set___)`).
|
|
82
174
|
with (scope) {
|
|
83
|
-
|
|
175
|
+
const value = eval(payload);
|
|
176
|
+
// A payload that called ecldResult/___etny_result___ itself
|
|
177
|
+
// already holds the finished [0, envelopeJson] tuple -- pass
|
|
178
|
+
// it through instead of wrapping the result a second time.
|
|
179
|
+
if (Array.isArray(value) && value.length === 2 &&
|
|
180
|
+
value[0] === 0 && typeof value[1] === 'string') {
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
return ___etny_result___(value);
|
|
84
184
|
}
|
|
85
185
|
} else {
|
|
86
186
|
return [TaskStatus.PAYLOAD_NOT_DEFINED, 'Could not find the source file to execute'];
|
|
@@ -436,6 +436,7 @@ class EtnySecureLock {
|
|
|
436
436
|
bucket: this.etny_bucket,
|
|
437
437
|
contractAddress: contractAddress,
|
|
438
438
|
provider: this.w3,
|
|
439
|
+
caller: this.task_caller || null,
|
|
439
440
|
});
|
|
440
441
|
console.log(`StateRegistry ready (ESR ${contractAddress})`);
|
|
441
442
|
} catch (e) {
|
|
@@ -456,13 +457,58 @@ class EtnySecureLock {
|
|
|
456
457
|
return false;
|
|
457
458
|
}
|
|
458
459
|
|
|
460
|
+
// Attested task caller: the DO-request owner, forwarded by the
|
|
461
|
+
// trustedzone as caller.securelock + .sig. Accepted ONLY with a valid
|
|
462
|
+
// trustedzone signature -- this build's crypto module has no verify
|
|
463
|
+
// helper yet, so until one lands the caller stays unset (anonymous)
|
|
464
|
+
// rather than trusting an unverifiable value.
|
|
465
|
+
this.task_caller = null;
|
|
466
|
+
try {
|
|
467
|
+
const callerValue = await this.getFileContentAndDecrypt(this.etny_bucket, 'caller.securelock');
|
|
468
|
+
const callerSig = await this.getFileContentAndDecrypt(this.etny_bucket, 'caller.securelock.sig');
|
|
469
|
+
if (typeof etny_crypto.verifySignature === 'function') {
|
|
470
|
+
await this.getTrustedZonePublicKey();
|
|
471
|
+
if (etny_crypto.verifySignature(this.trustedZonePublicKey, callerValue, callerSig)) {
|
|
472
|
+
this.task_caller = String(callerValue).trim();
|
|
473
|
+
console.log(`Task caller attested by trustedzone: ${this.task_caller}`);
|
|
474
|
+
} else {
|
|
475
|
+
console.error('caller.securelock signature validation FAILED; treating task as anonymous');
|
|
476
|
+
}
|
|
477
|
+
} else {
|
|
478
|
+
console.log('No signature-verify support in this build; ignoring caller.securelock (anonymous)');
|
|
479
|
+
}
|
|
480
|
+
} catch (e) {
|
|
481
|
+
console.log('No attested task caller supplied; treating task as anonymous');
|
|
482
|
+
}
|
|
483
|
+
|
|
459
484
|
this.configureStateRegistry();
|
|
460
485
|
|
|
461
|
-
const task_result = executeTask(payload_data, input_data);
|
|
486
|
+
const task_result = await executeTask(payload_data, input_data);
|
|
462
487
|
console.log('TASK RESULT (code, data):', task_result);
|
|
463
488
|
this.task_code = task_result[0];
|
|
464
489
|
this.task_result = task_result[1];
|
|
465
490
|
|
|
491
|
+
// Post-payload: re-assert the attested caller across the ESR ledger from
|
|
492
|
+
// THIS securelock's private copy (this.task_caller), captured before the
|
|
493
|
+
// payload ran and never handed to the payload. Corrects any callerUsed
|
|
494
|
+
// the payload may have forged (so the trustedzone reads the truth) and
|
|
495
|
+
// fails the task locally if a forgery is detected. Defence in depth;
|
|
496
|
+
// the trustedzone (separate enclave) and the CID-bound caller remain the
|
|
497
|
+
// authoritative layers.
|
|
498
|
+
try {
|
|
499
|
+
const ecld_state = require("./ecld_state");
|
|
500
|
+
if (typeof ecld_state.restampLedgerCaller === 'function') {
|
|
501
|
+
const tampered = await ecld_state.restampLedgerCaller(this.task_caller || null);
|
|
502
|
+
if (tampered) {
|
|
503
|
+
console.error('ESR ledger caller was tampered with by the payload -- failing the task (SECURITY_VIOLATION).');
|
|
504
|
+
this.task_code = TaskStatus.SECURITY_VIOLATION;
|
|
505
|
+
this.task_result = 'SECURITY_VIOLATION: the task attempted to forge the state commit caller. The attested submitter was re-asserted and the task was terminated.';
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
} catch (e) {
|
|
509
|
+
console.error('ESR ledger re-stamp skipped:', e && e.message ? e.message : e);
|
|
510
|
+
}
|
|
511
|
+
|
|
466
512
|
return true;
|
|
467
513
|
}
|
|
468
514
|
|
|
@@ -17,6 +17,10 @@ class TaskStatus {
|
|
|
17
17
|
static CONFIG_ERROR = 32;
|
|
18
18
|
static EXECUTION_TIMEOUT = 33; // Started but produced no result within the order duration.
|
|
19
19
|
static ESR_GAS_LIMIT_EXCEEDED = 34; // ESR commits would exceed the per-order relayed-gas budget.
|
|
20
|
+
static SECURITY_VIOLATION = 35; // A state commit was authorized under a caller other than
|
|
21
|
+
// the task's submitter (the in-enclave ownership check was
|
|
22
|
+
// bypassed). Set by the securelock on a detected forged
|
|
23
|
+
// caller, and by the trustedzone re-adjudication.
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
module.exports = {
|
package/nodenithy/build.mjs
CHANGED
|
@@ -115,6 +115,36 @@ try {
|
|
|
115
115
|
process.exit(1);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// Safety lint: flag dynamic code execution -- especially of task input --
|
|
119
|
+
// before the image is sealed. A hard error only for the case that actually
|
|
120
|
+
// opens the enclave to a submitter (eval / Function of ___etny_data_set___);
|
|
121
|
+
// everything else is a warning. See payloadLint.mjs for scope + opt-out.
|
|
122
|
+
try {
|
|
123
|
+
const { analyze } = await import('../payloadLint.mjs');
|
|
124
|
+
const src = fs.readFileSync(backendFile, 'utf8');
|
|
125
|
+
const { findings, optedOut } = analyze(src, 'src/serverless/backend.js');
|
|
126
|
+
if (!optedOut) {
|
|
127
|
+
const warnings = findings.filter((f) => f.severity === 'warning');
|
|
128
|
+
const errors = findings.filter((f) => f.severity === 'error');
|
|
129
|
+
for (const f of warnings) {
|
|
130
|
+
console.warn(`WARNING: src/serverless/backend.js:${f.line}: ${f.message}`);
|
|
131
|
+
}
|
|
132
|
+
if (errors.length) {
|
|
133
|
+
console.error('');
|
|
134
|
+
console.error('ERROR: unsafe dynamic execution of task input in src/serverless/backend.js:');
|
|
135
|
+
for (const f of errors) console.error(` line ${f.line}: ${f.message}`);
|
|
136
|
+
console.error('');
|
|
137
|
+
console.error(' This would let a task submitter run arbitrary code inside your enclave');
|
|
138
|
+
console.error(' and reach other users\' state. Fix it, or if the input is genuinely');
|
|
139
|
+
console.error(' trusted, add `// ecld: allow-eval` on that line to acknowledge the risk.');
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch (e) {
|
|
144
|
+
// The lint is best-effort; never block a build on the linter itself failing.
|
|
145
|
+
console.warn(`WARNING: payload safety lint skipped (${e && e.message ? e.message : e})`);
|
|
146
|
+
}
|
|
147
|
+
|
|
118
148
|
console.log(`Creating destination directory: ${destDir}`);
|
|
119
149
|
fs.mkdirSync(destDir, { recursive: true });
|
|
120
150
|
|
package/package.json
CHANGED
|
@@ -1,50 +1,51 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@ethernity-cloud/sdk-js",
|
|
3
|
-
"version": "1.5.
|
|
4
|
-
"description": "A package to run ethernity cloud build scripts",
|
|
5
|
-
"scripts": {
|
|
6
|
-
"ecld-build": "cross-env node ./node_modules/@ethernity-cloud/sdk-js/build.js",
|
|
7
|
-
"ecld-publish": "cross-env node ./node_modules/@ethernity-cloud/sdk-js/publish.js",
|
|
8
|
-
"ecld-init": "cross-env node ./node_modules/@ethernity-cloud/sdk-js/init.js",
|
|
9
|
-
"ecld-run": "cross-env ecld-run",
|
|
10
|
-
"postinstall": "node postinstall.js",
|
|
11
|
-
"ecld-test": "cross-env node ./node_modules/@ethernity-cloud/sdk-js/test.js"
|
|
12
|
-
},
|
|
13
|
-
"bin": {
|
|
14
|
-
"ecld-build": "./build.js",
|
|
15
|
-
"ecld-publish": "./publish.js",
|
|
16
|
-
"ecld-init": "./init.js",
|
|
17
|
-
"postinstall": "./postinstall.js",
|
|
18
|
-
"ecld-test": "./test.js"
|
|
19
|
-
},
|
|
20
|
-
"files": [
|
|
21
|
-
"nodenithy/*",
|
|
22
|
-
"pynithy/*",
|
|
23
|
-
"build.js",
|
|
24
|
-
"init.js",
|
|
25
|
-
"publish.js",
|
|
26
|
-
"postinstall.js"
|
|
27
|
-
],
|
|
28
|
-
"dependencies": {
|
|
29
|
-
"@ethernity-cloud/runner": "^0.0.26",
|
|
30
|
-
"axios": "^1.7.2",
|
|
31
|
-
"commander": "^12.1.0",
|
|
32
|
-
"dotenv": "^16.4.5",
|
|
33
|
-
"eth-lib": "^0.1.29",
|
|
34
|
-
"ethers": "^5.5.4",
|
|
35
|
-
"kubo-rpc-client": "^4.1.1",
|
|
36
|
-
"web3": "^4.9.0",
|
|
37
|
-
"cross-env": "^7.0.3",
|
|
38
|
-
"shelljs": "^0.8.5",
|
|
39
|
-
"shx": "^0.3.4",
|
|
40
|
-
"node-forge": "^1.3.1",
|
|
41
|
-
"cli-progress": "^3.12.0",
|
|
42
|
-
"react": "^18.3.1",
|
|
43
|
-
"react-dom": "^18.3.1",
|
|
44
|
-
"react-scripts": "^5.0.1",
|
|
45
|
-
"web-vitals": "^2.1.4",
|
|
46
|
-
"adm-zip": "^0.5.14"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@ethernity-cloud/sdk-js",
|
|
3
|
+
"version": "1.5.8",
|
|
4
|
+
"description": "A package to run ethernity cloud build scripts",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"ecld-build": "cross-env node ./node_modules/@ethernity-cloud/sdk-js/build.js",
|
|
7
|
+
"ecld-publish": "cross-env node ./node_modules/@ethernity-cloud/sdk-js/publish.js",
|
|
8
|
+
"ecld-init": "cross-env node ./node_modules/@ethernity-cloud/sdk-js/init.js",
|
|
9
|
+
"ecld-run": "cross-env ecld-run",
|
|
10
|
+
"postinstall": "node postinstall.js",
|
|
11
|
+
"ecld-test": "cross-env node ./node_modules/@ethernity-cloud/sdk-js/test.js"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"ecld-build": "./build.js",
|
|
15
|
+
"ecld-publish": "./publish.js",
|
|
16
|
+
"ecld-init": "./init.js",
|
|
17
|
+
"postinstall": "./postinstall.js",
|
|
18
|
+
"ecld-test": "./test.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"nodenithy/*",
|
|
22
|
+
"pynithy/*",
|
|
23
|
+
"build.js",
|
|
24
|
+
"init.js",
|
|
25
|
+
"publish.js",
|
|
26
|
+
"postinstall.js"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@ethernity-cloud/runner": "^0.0.26",
|
|
30
|
+
"axios": "^1.7.2",
|
|
31
|
+
"commander": "^12.1.0",
|
|
32
|
+
"dotenv": "^16.4.5",
|
|
33
|
+
"eth-lib": "^0.1.29",
|
|
34
|
+
"ethers": "^5.5.4",
|
|
35
|
+
"kubo-rpc-client": "^4.1.1",
|
|
36
|
+
"web3": "^4.9.0",
|
|
37
|
+
"cross-env": "^7.0.3",
|
|
38
|
+
"shelljs": "^0.8.5",
|
|
39
|
+
"shx": "^0.3.4",
|
|
40
|
+
"node-forge": "^1.3.1",
|
|
41
|
+
"cli-progress": "^3.12.0",
|
|
42
|
+
"react": "^18.3.1",
|
|
43
|
+
"react-dom": "^18.3.1",
|
|
44
|
+
"react-scripts": "^5.0.1",
|
|
45
|
+
"web-vitals": "^2.1.4",
|
|
46
|
+
"adm-zip": "^0.5.14",
|
|
47
|
+
"acorn": "^8.11.0"
|
|
48
|
+
},
|
|
49
|
+
"author": "Alexandru Luca",
|
|
50
|
+
"license": "AGPL-3.0"
|
|
51
|
+
}
|