@ethernity-cloud/sdk-js 1.5.5 → 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.
@@ -54,7 +54,7 @@ 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;
@@ -62,6 +62,104 @@ function configure({ identityPriv, swiftStreamService, bucket, contractAddress,
62
62
  _provider = provider;
63
63
  _esrLedger = [];
64
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 };
65
163
  }
66
164
 
67
165
  // Task-scoped ledger of every state key this execution touched, recorded by
@@ -247,10 +345,22 @@ class StateRegistry {
247
345
  * state" would let the next commit overwrite data that is still recoverable.
248
346
  */
249
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 = {}) {
250
361
  const [cid, version] = await this._contract().getState(this.walletAddress, keyHash(key));
251
362
  if (!version || !cid) {
252
- ledgerRecord(key, 0, null, fallback);
253
- return fallback;
363
+ return [null, fallback, 0, null];
254
364
  }
255
365
  if (!looksLikeCID(cid)) {
256
366
  throw new Error(
@@ -259,9 +369,9 @@ class StateRegistry {
259
369
  );
260
370
  }
261
371
  const blob = await this._fetch(key, cid);
262
- const state = JSON.parse(decrypt(blob).toString('utf8'));
263
- ledgerRecord(key, version, cid, state);
264
- return state;
372
+ const stored = JSON.parse(decrypt(blob).toString('utf8'));
373
+ const [acl, data] = unwrapStored(stored);
374
+ return [acl, data, Number(version), cid];
265
375
  }
266
376
 
267
377
  /**
@@ -272,15 +382,39 @@ class StateRegistry {
272
382
  * retry, so concurrent tasks cannot silently lose updates.
273
383
  */
274
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) {
275
407
  let lastError = null;
276
408
  for (let attempt = 0; attempt < attempts; attempt += 1) {
277
409
  // eslint-disable-next-line no-await-in-loop
278
- const currentVersion = await this.getVersion(key);
410
+ const [acl, data, currentVersion] = await this._readContainer(key);
279
411
  // eslint-disable-next-line no-await-in-loop
280
- const current = currentVersion ? await this.get(key) : {};
281
- const newState = await mutate(current);
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);
282
416
 
283
- const blob = encrypt(Buffer.from(JSON.stringify(newState), 'utf8'));
417
+ const blob = encrypt(Buffer.from(JSON.stringify(stored), 'utf8'));
284
418
  // Compute the CID from OUR bytes — never accept one from the node.
285
419
  const cid = cidv1Raw(blob);
286
420
 
@@ -291,11 +425,12 @@ class StateRegistry {
291
425
 
292
426
  try {
293
427
  // eslint-disable-next-line no-await-in-loop
294
- await this._sendCommit(keyHash(key), cid, currentVersion);
295
- // Record the POST-commit values: this is what the chain will show
296
- // once the node's relay lands (version increments by one).
297
- ledgerRecord(key, currentVersion + 1, cid, newState);
298
- return newState;
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];
299
434
  } catch (e) {
300
435
  const msg = String(e && e.message ? e.message : e);
301
436
  if (/VersionMismatch/i.test(msg) || /version/i.test(msg)) {
@@ -339,7 +474,7 @@ class StateRegistry {
339
474
  throw new Error(`Could not read state object for '${key}' (${cid})`);
340
475
  }
341
476
 
342
- async _sendCommit(keyHashHex, cid, expectedVersion) {
477
+ async _sendCommit(keyHashHex, cid, expectedVersion, ownerAddr = null) {
343
478
  // The enclave never pays gas and does NO gas math: it SIGNS the commit
344
479
  // (commitFor) and stages the signed authorization for the node, which
345
480
  // relays it and pays. The trustedzone independently re-prices the whole
@@ -362,7 +497,15 @@ class StateRegistry {
362
497
  cid,
363
498
  expectedVersion: Number(expectedVersion),
364
499
  relayNonce: Number(relayNonce),
365
- 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)
366
509
  };
367
510
 
368
511
  _esrLedger.push(auth);
@@ -376,10 +519,100 @@ class StateRegistry {
376
519
  }
377
520
  }
378
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
+
379
603
  module.exports = {
380
604
  StateRegistry,
381
605
  configure,
382
606
  cidv1Raw,
383
607
  looksLikeCID,
384
- ledgerSnapshot
608
+ ledgerSnapshot,
609
+ taskCaller,
610
+ restampLedgerCaller,
611
+ StatePermissionError,
612
+ esrGrant,
613
+ esrRevoke,
614
+ esrSetPublicRead,
615
+ esrTransfer,
616
+ esrOwner,
617
+ esrAcl
385
618
  };