@ethernity-cloud/sdk-js 1.5.5 → 1.5.9

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 ADDED
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ecld-info: everything a developer needs to read or troubleshoot an enclave.
4
+ *
5
+ * Read-only and free (eth_call + event logs -- no task, no gas, no private
6
+ * key). Reads your project's identity from .env (PROJECT_NAME / IPFS_HASH /
7
+ * BLOCKCHAIN_NETWORK), then reports:
8
+ *
9
+ * NETWORK name, type, chain id, RPC, contract addresses, live block
10
+ * TRUSTEDZONE the gatekeeper enclave's Image Registry record
11
+ * SECURELOCK the executor enclave's Image Registry record
12
+ * ESR registry address, total keys, and -- with --enclave <wallet>
13
+ * -- this enclave's recent StateCommitted events
14
+ *
15
+ * Subcommands (default: the full summary):
16
+ * ecld-info full summary
17
+ * ecld-info network just the network section
18
+ * ecld-info trustedzone just the trustedzone registration
19
+ * ecld-info securelock just the securelock registration
20
+ * ecld-info esr address|count|state|version|list ESR inspection
21
+ *
22
+ * Registration is keyed by the image's IPFS hash: pass --ipfs <hash> for an
23
+ * exact record, or --name <n> to resolve the latest registered version of a
24
+ * name. With neither, .env IPFS_HASH / PROJECT_NAME are used.
25
+ *
26
+ * Network: --network NAME, else ESR_NETWORK env, else .env BLOCKCHAIN_NETWORK,
27
+ * else BLOXBERG_TESTNET.
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+ const { ethers } = require('ethers');
35
+ try { require('dotenv').config(); } catch (e) { /* dotenv optional */ }
36
+
37
+ // Authoritative per-network values (mirror of the Python SDK's network table).
38
+ const NETWORKS = {
39
+ BLOXBERG_MAINNET: {
40
+ type: 'mainnet', chainId: 8995, rpc: 'https://core.bloxberg.org',
41
+ protocol: '0x549A6E06BB2084100148D50F51CF77a3436C3Ae7',
42
+ imageRegistry: '0x15D73a742529C3fb11f3FA32EF7f0CC3870ACA31',
43
+ esr: '0xF76469A5659670B6ade366dE635e6463aaB8f3D8',
44
+ },
45
+ BLOXBERG_TESTNET: {
46
+ type: 'testnet', chainId: 8995, rpc: 'https://core.bloxberg.org',
47
+ protocol: '0x02882F03097fE8cD31afbdFbB5D72a498B41112c',
48
+ imageRegistry: '0x15D73a742529C3fb11f3FA32EF7f0CC3870ACA31',
49
+ esr: '0xda5e68Bb5e68ee14D73b8de2a4D3Ca15736fACfb',
50
+ },
51
+ LITVM_LITEFORGE: {
52
+ type: 'testnet', chainId: 0, rpc: 'https://liteforge.rpc.caldera.xyz/infra-partner-http',
53
+ protocol: '', imageRegistry: '', esr: '0xbAa7F9E3287ff95D177104eD469E6d0Fd19dBB0F',
54
+ },
55
+ };
56
+
57
+ const IMAGE_REGISTRY_ABI = [
58
+ 'function imageDetails(string) view returns (address owner, string ipfsHash, string version, string session, uint256 fee, address rewardAddress, bool validated, bool published, string certPublicKey, string dockerComposeHash, string name)',
59
+ 'function trustedZoneImageDetails(string) view returns (address owner, string ipfsHash, string version, string session, uint256 fee, address rewardAddress, bool validated, bool published, string certPublicKey, string dockerComposeHash, string name)',
60
+ 'function imageVersions(string, uint256) view returns (string)',
61
+ 'function trustedZoneImageVersions(string, uint256) view returns (string)',
62
+ ];
63
+
64
+ const ESR_ABI = [
65
+ 'function getState(address enclave, bytes32 key) view returns (string cid, uint256 version, uint64 updatedAt)',
66
+ 'function getVersion(address enclave, bytes32 key) view returns (uint256)',
67
+ 'function exists(address enclave, bytes32 key) view returns (bool)',
68
+ 'function entryCount() view returns (uint256)',
69
+ 'function getEntriesFrom(uint256 start, uint256 limit) view returns (address[] enclaves, bytes32[] keys, string[] cids, uint256[] versions, uint64[] updatedAts, uint256 total)',
70
+ 'event StateCommitted(address indexed enclave, bytes32 indexed key, string cid, uint256 version, uint256 seq)',
71
+ ];
72
+
73
+ function resolveNetwork(name) {
74
+ name = (name || process.env.ESR_NETWORK || process.env.BLOCKCHAIN_NETWORK || 'BLOXBERG_TESTNET')
75
+ .toUpperCase();
76
+ const net = NETWORKS[name];
77
+ if (!net) {
78
+ throw new Error(`Unknown network '${name}'. Known: ${Object.keys(NETWORKS).join(', ')}`);
79
+ }
80
+ return { name, net };
81
+ }
82
+
83
+ function provider(rpc) {
84
+ return new ethers.providers.JsonRpcProvider(rpc);
85
+ }
86
+
87
+ function keyHash(key) {
88
+ return ethers.utils.id(key); // keccak256(utf8(key))
89
+ }
90
+
91
+ function looksLikeCID(cid) {
92
+ return typeof cid === 'string' && (cid.startsWith('Qm') || cid.startsWith('bafk')) && cid.length > 20;
93
+ }
94
+
95
+ function decodeDetails(d, fallbackName) {
96
+ return {
97
+ name: d.name || fallbackName,
98
+ published: Boolean(d.published),
99
+ validated: Boolean(d.validated),
100
+ image_ipfs_hash: d.ipfsHash || null,
101
+ image_version: d.version || null,
102
+ reward_address: d.rewardAddress,
103
+ owner: d.owner,
104
+ docker_compose_hash: d.dockerComposeHash || null,
105
+ attestation_session: Boolean(d.session),
106
+ cert_present: Boolean(d.certPublicKey),
107
+ };
108
+ }
109
+
110
+ async function lookupRegistration(reg, kind, ipfs, name) {
111
+ const detailsFn = kind === 'trustedzone' ? 'trustedZoneImageDetails' : 'imageDetails';
112
+ const versionsFn = kind === 'trustedzone' ? 'trustedZoneImageVersions' : 'imageVersions';
113
+ if (ipfs) {
114
+ try { return decodeDetails(await reg[detailsFn](ipfs), ipfs); }
115
+ catch (e) { return { found: false, error: e.message }; }
116
+ }
117
+ if (name) {
118
+ let latest = null;
119
+ for (let i = 0; i < 64; i += 1) {
120
+ let v;
121
+ try { v = await reg[versionsFn](name, i); } catch (e) { break; }
122
+ if (!v) break;
123
+ latest = v;
124
+ }
125
+ if (latest) {
126
+ try { return decodeDetails(await reg[detailsFn](latest), name); }
127
+ catch (e) { return { found: false, error: e.message }; }
128
+ }
129
+ return { found: false, note: `no registered version for name '${name}' -- the Image Registry is keyed by image IPFS hash; pass --ipfs <hash>` };
130
+ }
131
+ return { found: false, note: 'no --ipfs or --name to look up' };
132
+ }
133
+
134
+ async function sectionNetwork(prov, name, net) {
135
+ const out = {
136
+ network: name, type: net.type, chain_id: net.chainId, rpc: net.rpc,
137
+ protocol_contract: net.protocol || '(n/a)',
138
+ image_registry: net.imageRegistry || '(n/a)',
139
+ esr_contract: net.esr || '(not deployed)',
140
+ };
141
+ try {
142
+ out.latest_block = await prov.getBlockNumber();
143
+ out.connected = true;
144
+ } catch (e) {
145
+ out.connected = false; out.error = e.message;
146
+ }
147
+ return out;
148
+ }
149
+
150
+ async function sectionRegistration(prov, net, kind, ipfs, name) {
151
+ if (!net.imageRegistry) return { note: 'no image registry on this network' };
152
+ const reg = new ethers.Contract(net.imageRegistry, IMAGE_REGISTRY_ABI, prov);
153
+ return lookupRegistration(reg, kind, ipfs, name);
154
+ }
155
+
156
+ async function sectionEsr(prov, net, enclaveWallet, eventsN) {
157
+ if (!net.esr) return { note: 'ESR is not deployed on this network' };
158
+ const esr = new ethers.Contract(net.esr, ESR_ABI, prov);
159
+ const out = { esr_contract: net.esr };
160
+ try { out.total_registry_entries = (await esr.entryCount()).toNumber(); } catch (e) { /* ignore */ }
161
+ if (enclaveWallet) {
162
+ try {
163
+ const latest = await prov.getBlockNumber();
164
+ const from = Math.max(0, latest - 500000);
165
+ const flt = esr.filters.StateCommitted(ethers.utils.getAddress(enclaveWallet));
166
+ let logs = await esr.queryFilter(flt, from, 'latest');
167
+ logs = logs.slice(-eventsN);
168
+ out.recent_commits = logs.map((l) => ({
169
+ key_hash: l.args.key,
170
+ version: l.args.version.toNumber(),
171
+ cid: l.args.cid,
172
+ seq: l.args.seq.toNumber(),
173
+ block: l.blockNumber,
174
+ }));
175
+ } catch (e) {
176
+ out.recent_commits_error = e.message;
177
+ }
178
+ } else {
179
+ out.recent_commits_note = 'pass --enclave <ESR wallet> to list this enclave\'s state commits';
180
+ }
181
+ return out;
182
+ }
183
+
184
+ async function esrQuery(prov, name, net, args) {
185
+ const esrAddr = args.contract || net.esr;
186
+ const sub = args.esrCmd || 'address';
187
+ if (sub === 'address') return { network: name, esr_contract: esrAddr || '(not deployed)', rpc: net.rpc };
188
+ if (!esrAddr) throw new Error(`ESR is not deployed on ${name}.`);
189
+ const c = new ethers.Contract(esrAddr, ESR_ABI, prov);
190
+ if (sub === 'count') return { network: name, entry_count: (await c.entryCount()).toNumber() };
191
+ if (sub === 'version') {
192
+ const v = (await c.getVersion(ethers.utils.getAddress(args.enclave), keyHash(args.key))).toNumber();
193
+ return { enclave: args.enclave, key: args.key, version: v };
194
+ }
195
+ if (sub === 'state') {
196
+ const enclave = ethers.utils.getAddress(args.enclave);
197
+ const kh = keyHash(args.key);
198
+ const [cid, version, updated] = await c.getState(enclave, kh);
199
+ const exists = await c.exists(enclave, kh);
200
+ return {
201
+ network: name, enclave: args.enclave, key: args.key, key_hash: kh,
202
+ exists, version: version.toNumber(), cid: cid || null, cid_valid: looksLikeCID(cid),
203
+ updated_at: updated.toNumber(),
204
+ note: 'cid points at ENCRYPTED state; only the enclave can decrypt it',
205
+ };
206
+ }
207
+ if (sub === 'list') {
208
+ const start = parseInt(args.start || 0, 10);
209
+ const limit = parseInt(args.limit || 50, 10);
210
+ const [encs, keys, cids, versions, updated, total] = await c.getEntriesFrom(start, limit);
211
+ const entries = encs.map((e, i) => ({
212
+ enclave: e, key_hash: keys[i], cid: cids[i] || null,
213
+ version: versions[i].toNumber(), updated_at: updated[i].toNumber(),
214
+ }));
215
+ return { network: name, total: total.toNumber(), start, returned: entries.length, entries };
216
+ }
217
+ throw new Error(`unknown esr subcommand: ${sub}`);
218
+ }
219
+
220
+ // ---- arg parsing (tiny, dependency-free) -----------------------------------
221
+
222
+ function parseArgs(argv) {
223
+ const a = { _: [], json: false };
224
+ for (let i = 0; i < argv.length; i += 1) {
225
+ const t = argv[i];
226
+ if (t === '--json') a.json = true;
227
+ else if (t.startsWith('--')) { a[t.slice(2)] = argv[i + 1]; i += 1; }
228
+ else a._.push(t);
229
+ }
230
+ return a;
231
+ }
232
+
233
+ function emit(obj, asJson) {
234
+ if (asJson) { console.log(JSON.stringify(obj, null, 2)); return; }
235
+ for (const [k, v] of Object.entries(obj)) {
236
+ if (Array.isArray(v)) { console.log(`${k}:`); v.forEach((it) => console.log(' ' + JSON.stringify(it))); }
237
+ else console.log(`${k}: ${v}`);
238
+ }
239
+ }
240
+
241
+ function printFull(info) {
242
+ const n = info.network;
243
+ console.log('NETWORK');
244
+ console.log(` ${n.network} (${n.type}, chain ${n.chain_id}) block ${n.latest_block || '?'} ${n.connected ? 'connected' : 'NOT CONNECTED'}`);
245
+ console.log(` rpc: ${n.rpc}`);
246
+ console.log(` protocol: ${n.protocol_contract}`);
247
+ console.log(` image registry: ${n.image_registry}`);
248
+ console.log(` esr: ${n.esr_contract}`);
249
+ for (const [label, key] of [['TRUSTEDZONE', 'trustedzone'], ['SECURELOCK', 'securelock']]) {
250
+ const r = info[key] || {};
251
+ console.log(label);
252
+ if (r.published || r.validated || r.image_ipfs_hash) {
253
+ console.log(` published: ${r.published ? 'yes' : 'no'} validated: ${r.validated ? 'yes' : 'no'}`);
254
+ console.log(` image hash: ${r.image_ipfs_hash}`);
255
+ console.log(` version: ${r.image_version}`);
256
+ console.log(` reward addr: ${r.reward_address}`);
257
+ console.log(` attestation: ${r.attestation_session ? 'MRENCLAVE session present' : 'none'}`);
258
+ } else {
259
+ console.log(` not found (${r.error || r.note || 'not registered'})`);
260
+ }
261
+ }
262
+ const e = info.esr || {};
263
+ console.log('ESR');
264
+ if (e.note) { console.log(` ${e.note}`); return; }
265
+ console.log(` registry: ${e.esr_contract}`);
266
+ if ('total_registry_entries' in e) console.log(` total keys: ${e.total_registry_entries} (all enclaves)`);
267
+ if (e.recent_commits) {
268
+ console.log(` recent state commits for this enclave: ${e.recent_commits.length}`);
269
+ e.recent_commits.forEach((c) => console.log(` v${c.version} seq ${c.seq} ${c.key_hash} ${c.cid} (block ${c.block})`));
270
+ } else if (e.recent_commits_note) console.log(` ${e.recent_commits_note}`);
271
+ else if (e.recent_commits_error) console.log(` (could not read commits: ${e.recent_commits_error})`);
272
+ }
273
+
274
+ async function main() {
275
+ const args = parseArgs(process.argv.slice(2));
276
+ const section = args._[0]; // network | trustedzone | securelock | esr | undefined
277
+ const { name: netName, net } = resolveNetwork(args.network);
278
+ const prov = provider(net.rpc);
279
+
280
+ const ipfs = args.ipfs || process.env.IPFS_HASH || null;
281
+ const projName = args.name || process.env.PROJECT_NAME || null;
282
+
283
+ if (section === 'esr') {
284
+ args.esrCmd = args._[1];
285
+ const obj = await esrQuery(prov, netName, net, args);
286
+ emit(obj, args.json);
287
+ return;
288
+ }
289
+ if (section === 'network') { emit(await sectionNetwork(prov, netName, net), args.json); return; }
290
+ if (section === 'trustedzone') { emit(await sectionRegistration(prov, net, 'trustedzone', ipfs, projName), args.json); return; }
291
+ if (section === 'securelock') { emit(await sectionRegistration(prov, net, 'securelock', ipfs, projName), args.json); return; }
292
+
293
+ const info = {
294
+ project: { name: projName || '(unknown)', dapp_type: process.env.SERVICE_TYPE || process.env.DAPP_TYPE, version: process.env.VERSION },
295
+ network: await sectionNetwork(prov, netName, net),
296
+ trustedzone: await sectionRegistration(prov, net, 'trustedzone', ipfs, projName),
297
+ securelock: await sectionRegistration(prov, net, 'securelock', ipfs, projName),
298
+ esr: await sectionEsr(prov, net, args.enclave || null, parseInt(args.events || 10, 10)),
299
+ };
300
+ if (args.json) { console.log(JSON.stringify(info, null, 2)); return; }
301
+ const pr = info.project;
302
+ console.log(`PROJECT ${pr.name}${pr.dapp_type ? ' type ' + pr.dapp_type : ''}${pr.version != null ? ' version ' + pr.version : ''}`);
303
+ printFull(info);
304
+ }
305
+
306
+ main().catch((e) => { console.error(`ecld-info: ${e.message}`); process.exit(1); });
@@ -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
  };