@ethernity-cloud/sdk-js 1.7.15 → 1.7.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,9 +44,9 @@ The sdk has been tested on the following operating systems:
44
44
  `ecld-publish` registers the image in the ImageRegistry
45
45
  (ECImageRegistryV3, `0xa372a6e1...`) before anything discloses its
46
46
  name, which makes the name the publishing wallet's; then registers the
47
- securelock session on-chain in the SessionRegistry (`0x44442968...`, see
47
+ securelock session on-chain in the SessionRegistry (`0x78f0c5E9...`, see
48
48
  `cas/config.js`), which takes it only from that wallet, resolves a CAS
49
- from the ValidatorRegistry (`0x0014aB10...`, or `ECLD_CAS_ADDR=host:port`)
49
+ from the ValidatorRegistry (`0x8CC8B1dc...`, or `ECLD_CAS_ADDR=host:port`)
50
50
  for the compose and the certificate harvest, takes the trustedzone
51
51
  session name from the ImageRegistry, and links the session to the
52
52
  published image. `ecld-build` signs the securelock `--production`.
@@ -63,8 +63,9 @@ RUN openssl genrsa -3 -out /enclave-key.pem 3072
63
63
  # scone-signer below (SCONE embeds defaults for anything not signed explicitly;
64
64
  # any drift makes SCONE recompute + debug re-sign the enclave at load, which CAS
65
65
  # rejects on mainnet). Values mirror the publish/run docker-compose securelock
66
- # service (SCONE_HEAP=1024M, SCONE_STACK=4M, dlopen=1, extensions=libbinary-fs).
67
- ENV SCONE_HEAP=1024M
66
+ # service (SCONE_HEAP as build.mjs's MEMORY_TO_ALLOCATE, SCONE_STACK=4M,
67
+ # dlopen=1, extensions=libbinary-fs).
68
+ ENV SCONE_HEAP=__MEMORY_TO_ALLOCATE__
68
69
  ENV SCONE_STACK=4M
69
70
  ENV SCONE_ALLOW_DLOPEN=1
70
71
  ENV SCONE_EXTENSIONS_PATH=/lib/libbinary-fs.so
@@ -72,6 +72,7 @@ function configure({ identityPriv, swiftStreamService, bucket, contractAddress,
72
72
  _provider = provider;
73
73
  _esrLedger = [];
74
74
  _taskNonces = {};
75
+ _taskWrites = {};
75
76
  _taskRelayNonces = {};
76
77
  _taskLedger = {};
77
78
  _taskCaller = normAddr(caller);
@@ -116,6 +117,13 @@ class StateNonceError extends Error {}
116
117
  // task code 38 (ESR_COMMIT_LIMIT_EXCEEDED).
117
118
  const MAX_COMMITS_PER_TASK = 256;
118
119
 
120
+ // How long a read waits for the node to stage a state object the chain names
121
+ // before the task fails, and how often it looks for it: the node writes the
122
+ // objects pinned on it within seconds of the order's start and the ones it has
123
+ // to fetch from the network as they arrive.
124
+ const STATE_STAGE_WAIT_MS = 120000;
125
+ const STATE_STAGE_POLL_MS = 2000;
126
+
119
127
  class StateLimitError extends Error {}
120
128
 
121
129
  function unwrapStored(stored) {
@@ -260,15 +268,86 @@ function stateKey() {
260
268
  return keccak256(Buffer.concat([Buffer.from(DOMAIN_SEP_STATE, 'utf8'), material]));
261
269
  }
262
270
 
271
+ // A state object is stored as the node's Kubo stores it with `add
272
+ // --cid-version=1 --raw-leaves` and its default chunker: in raw sha256 blocks
273
+ // of BLOCK_SIZE bytes (the last one shorter), a single block as it is, several
274
+ // under a balanced UnixFS tree of at most LINKS_PER_NODE blocks per node.
275
+ // unixfsCid() computes the resulting CID from the bytes, so the enclave
276
+ // commits a CID it derived itself rather than one a node handed it, and the
277
+ // node stores the object under that CID (mvp-pox-node utils.py unixfs_cid).
278
+ const BLOCK_SIZE = 262144;
279
+ const LINKS_PER_NODE = 174;
280
+
281
+ function cidB32(cid) {
282
+ return 'b' + base32Encode(cid).toLowerCase().replace(/=+$/, '');
283
+ }
284
+
285
+ function pbVarint(n) {
286
+ const out = [];
287
+ for (;;) {
288
+ const byte = n % 128;
289
+ n = Math.floor(n / 128);
290
+ if (n === 0) {
291
+ out.push(byte);
292
+ return Buffer.from(out);
293
+ }
294
+ out.push(byte | 0x80);
295
+ }
296
+ }
297
+
298
+ // A protobuf field: varint (wire 0) or length-delimited (wire 2).
299
+ function pbField(number, wire, payload) {
300
+ const tag = pbVarint(number * 8 + wire);
301
+ return wire === 2 ? Buffer.concat([tag, pbVarint(payload.length), payload]) : Buffer.concat([tag, payload]);
302
+ }
303
+
304
+ // CIDv1, codec raw (0x55), sha2-256 (0x12, 32 bytes).
305
+ function pbRawCid(content) {
306
+ return Buffer.concat([Buffer.from([0x01, 0x55, 0x12, 0x20]), crypto.createHash('sha256').update(content).digest()]);
307
+ }
308
+
309
+ // A dag-pb UnixFS File node over `children`, each [cid, tsize, filesize];
310
+ // returns [cid, tsize, filesize]. Links come before Data in dag-pb; a link
311
+ // carries the child's CID, an empty Name and its Tsize, the size of the
312
+ // child's subtree; Data is UnixFS Type=File(2), filesize, blocksizes.
313
+ function pbFileNode(children) {
314
+ const links = Buffer.concat(children.map(([cid, tsize]) =>
315
+ pbField(2, 2, Buffer.concat([pbField(1, 2, cid), pbField(2, 2, Buffer.alloc(0)), pbField(3, 0, pbVarint(tsize))]))));
316
+ const filesize = children.reduce((n, [, , size]) => n + size, 0);
317
+ const data = Buffer.concat([pbField(1, 0, pbVarint(2)), pbField(3, 0, pbVarint(filesize)),
318
+ ...children.map(([, , size]) => pbField(4, 0, pbVarint(size)))]);
319
+ const block = Buffer.concat([links, pbField(1, 2, data)]);
320
+ const cid = Buffer.concat([Buffer.from([0x01, 0x70, 0x12, 0x20]), crypto.createHash('sha256').update(block).digest()]);
321
+ return [cid, block.length + children.reduce((n, [, tsize]) => n + tsize, 0), filesize];
322
+ }
323
+
263
324
  /**
264
- * CIDv1/raw/sha2-256 for `content`, computed without touching IPFS.
265
- * Matches `ipfs add --cid-version=1 --raw-leaves`, which is what lets the
266
- * enclave commit a CID it derived itself rather than one a node handed it.
325
+ * The CID the node's Kubo gives `content`: the raw block's CID up to one
326
+ * block, else the root of the balanced UnixFS tree over the blocks,
327
+ * LINKS_PER_NODE to a node, every node full but the last.
328
+ */
329
+ function unixfsCid(content, blockSize = BLOCK_SIZE) {
330
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content);
331
+ if (buf.length <= blockSize) return cidB32(pbRawCid(buf));
332
+ let nodes = [];
333
+ for (let i = 0; i < buf.length; i += blockSize) {
334
+ const chunk = buf.subarray(i, i + blockSize);
335
+ nodes.push([pbRawCid(chunk), chunk.length, chunk.length]);
336
+ }
337
+ while (nodes.length > 1) {
338
+ const next = [];
339
+ for (let i = 0; i < nodes.length; i += LINKS_PER_NODE) next.push(pbFileNode(nodes.slice(i, i + LINKS_PER_NODE)));
340
+ nodes = next;
341
+ }
342
+ return cidB32(nodes[0][0]);
343
+ }
344
+
345
+ /**
346
+ * CIDv1/raw/sha2-256 for `content`: its CID as one raw block, which is its CID
347
+ * through the node's Kubo up to BLOCK_SIZE bytes.
267
348
  */
268
349
  function cidv1Raw(content) {
269
- const digest = crypto.createHash('sha256').update(content).digest();
270
- const raw = Buffer.concat([Buffer.from([0x01, 0x55, 0x12, 0x20]), digest]);
271
- return 'b' + base32Encode(raw).toLowerCase().replace(/=+$/, '');
350
+ return cidB32(pbRawCid(content));
272
351
  }
273
352
 
274
353
  // RFC4648 base32, no padding stripped here (the caller strips it).
@@ -356,6 +435,12 @@ let _esrLedger = [];
356
435
  // even before the node's relay lands on-chain.
357
436
  let _taskNonces = {};
358
437
 
438
+ // Per-task memo of this task's last commit per key, [acl, data, version, cid]
439
+ // as the chain shows them once the node's relay lands. A read prefers it while
440
+ // the chain still shows an older version, so get() after commit() returns the
441
+ // committed state and a second commit to the key builds on the first.
442
+ let _taskWrites = {};
443
+
359
444
  // Per-task memo of the NEXT relay nonce per key hash, so multiple commits to
360
445
  // the same key within one task sign sequential relay nonces even while the
361
446
  // node is still relaying the earlier ones.
@@ -429,6 +514,10 @@ class StateRegistry {
429
514
  /** [acl, data, version, cid] for `key`; NO permission check here. */
430
515
  async _readContainer(key, fallback = {}) {
431
516
  const [cid, version] = await this._contract().getState(this.walletAddress, keyHash(key));
517
+ const own = _taskWrites[key];
518
+ if (own && own[2] > Number(version || 0)) {
519
+ return own;
520
+ }
432
521
  if (!version || !cid) {
433
522
  return [null, fallback, 0, null];
434
523
  }
@@ -579,7 +668,7 @@ class StateRegistry {
579
668
 
580
669
  const blob = encrypt(Buffer.from(JSON.stringify(stored), 'utf8'));
581
670
  // Compute the CID from OUR bytes — never accept one from the node.
582
- const cid = cidv1Raw(blob);
671
+ const cid = unixfsCid(blob);
583
672
 
584
673
  // Hand the blob and the CID to the node for pinning. It verifies the CID
585
674
  // matches; it does not get to choose it. Fire-and-forget.
@@ -597,6 +686,7 @@ class StateRegistry {
597
686
  // results): this is what the chain shows once the relay lands.
598
687
  ledgerRecord(key, currentVersion + 1, cid, newData);
599
688
  _taskNonces[key] = finalNonce;
689
+ _taskWrites[key] = [nextAcl, newData, currentVersion + 1, cid];
600
690
  return [nextAcl, newData];
601
691
  } catch (e) {
602
692
  const msg = String(e && e.message ? e.message : e);
@@ -623,22 +713,40 @@ class StateRegistry {
623
713
  * the node stages state objects in the bucket. That makes the node the
624
714
  * delivery path but not a trusted one: content is checked against the CID the
625
715
  * chain records, so a substituted blob is rejected rather than decrypted.
716
+ *
717
+ * The node stages it as state.<cid>.enc, within seconds of the order's start
718
+ * when the object is pinned on it and as it arrives when it has to fetch it
719
+ * from the network, so the object is looked for every STATE_STAGE_POLL_MS
720
+ * for up to STATE_STAGE_WAIT_MS. state.<key>.enc is the last blob a commit
721
+ * of this key published, which matches only while no later commit was
722
+ * published; one that does not match is passed over.
626
723
  */
627
724
  async _fetch(key, cid) {
628
- for (const name of [`state.${key}.enc`, `state.${cid}.enc`]) {
629
- // eslint-disable-next-line no-await-in-loop
630
- const data = await _swift.getFileContentBytes(_bucket, name).catch(() => null);
631
- if (data && data.length) {
632
- const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
633
- if (cidv1Raw(buf) !== cid) {
634
- throw new Error(
635
- `State object ${name} does not match the committed CID ${cid} — refusing to use it`
636
- );
725
+ const deadline = Date.now() + STATE_STAGE_WAIT_MS;
726
+ let mismatched = [];
727
+ for (;;) {
728
+ mismatched = [];
729
+ for (const name of [`state.${cid}.enc`, `state.${key}.enc`]) {
730
+ // eslint-disable-next-line no-await-in-loop
731
+ const data = await _swift.getFileContentBytes(_bucket, name).catch(() => null);
732
+ if (data && data.length) {
733
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
734
+ if (unixfsCid(buf) === cid) {
735
+ return buf;
736
+ }
737
+ mismatched.push(name);
637
738
  }
638
- return buf;
639
739
  }
740
+ if (Date.now() >= deadline) break;
741
+ // eslint-disable-next-line no-await-in-loop
742
+ await new Promise((resolve) => { setTimeout(resolve, STATE_STAGE_POLL_MS); });
743
+ }
744
+ if (mismatched.length) {
745
+ throw new Error(
746
+ `State object ${mismatched.join(' and ')} does not match the committed CID ${cid} — refusing to use it`
747
+ );
640
748
  }
641
- throw new Error(`Could not read state object for '${key}' (${cid})`);
749
+ throw new Error(`Could not read state object for '${key}' (${cid}): not staged within ${STATE_STAGE_WAIT_MS / 1000} s`);
642
750
  }
643
751
 
644
752
  async _sendCommit(keyHashHex, cid, expectedVersion, ownerAddr = null, nonce = 0) {
@@ -815,6 +923,7 @@ module.exports = {
815
923
  StatePermissionError,
816
924
  StateNonceError,
817
925
  StateLimitError,
926
+ unixfsCid,
818
927
  esrGrant,
819
928
  esrRevoke,
820
929
  esrSetPublicRead,
@@ -265,7 +265,12 @@ let dockerfileSecureContent = dockerfileSecureTemplate.replace(/__ENCLAVE_NAME_S
265
265
 
266
266
  // Amount of enclave heap to allocate (SCONE_HEAP). Kept in sync with the
267
267
  // run/docker-compose securelock service; overridable via ECLD_MEMORY_TO_ALLOCATE.
268
+ // 1024M: with 256M or 512M the node runtime exhausts the protected heap
269
+ // reserving its V8 code range at start, before the securelock runs.
268
270
  const MEMORY_TO_ALLOCATE = (process.env.ECLD_MEMORY_TO_ALLOCATE || '1024M').trim();
271
+ // Read back by ecld-publish (run.js), which renders it into the composes'
272
+ // securelock service.
273
+ writeEnv('MEMORY_TO_ALLOCATE', MEMORY_TO_ALLOCATE);
269
274
 
270
275
  // CRITICAL (mainnet DCAP): sign /usr/local/bin/node -- the binary the enclave
271
276
  // actually EXECUTES (ENTRYPOINT + the run/publish compose command both run
@@ -297,6 +302,7 @@ const signedMrenclaveStep =
297
302
  // securelock is signed debug with the same explicit params.
298
303
  const cas = casConfig.casProvisioned(templateName, isMainnet);
299
304
  dockerfileSecureContent = dockerfileSecureContent
305
+ .replace(/__MEMORY_TO_ALLOCATE__/g, MEMORY_TO_ALLOCATE)
300
306
  .replace('__SCONE_SIGN__', `RUN scone-signer sign ${signFlags}${cas ? ' --production' : ''} /usr/local/bin/node`)
301
307
  .replace('__SIGNED_MRENCLAVE__', signedMrenclaveStep);
302
308
 
@@ -36,9 +36,10 @@ services:
36
36
  - SCONE_CAS_ADDR=scone-cas.cf # __CAS_ONLY__
37
37
  - SCONE_LAS_ADDR=las # __CAS_ONLY__
38
38
  - SCONE_CONFIG_ID=__ENCLAVE_NAME_SECURELOCK__/application # __CAS_ONLY__
39
- - SCONE_HEAP=1024M
40
- # Must match the --stack the securelock binary was signed with (build.mjs),
41
- # otherwise SCONE recomputes MRENCLAVE at load and re-signs as debug.
39
+ # Must match the --heap and --stack the securelock binary was signed with
40
+ # (build.mjs), otherwise SCONE recomputes MRENCLAVE at load and re-signs
41
+ # as debug. The heap is the build's MEMORY_TO_ALLOCATE (.env).
42
+ - SCONE_HEAP=__MEMORY_TO_ALLOCATE__
42
43
  - SCONE_STACK=4M
43
44
  - SCONE_LOG=DEBUG
44
45
  - SCONE_ALLOW_DLOPEN=1
@@ -55,8 +56,8 @@ services:
55
56
  container_name: etny-trustedzone
56
57
  privileged: true
57
58
  image: localhost:5000/etny-trustedzone
58
- entrypoint: ""
59
- command: [ "/usr/local/bin/node", "/etny-trustedzone/trustedzone.js" ]
59
+ # No command: the image's ENTRYPOINT runs node with the flags the
60
+ # trustedzone was built and measured with (etny-nodenithy Dockerfile.tmpl).
60
61
  environment:
61
62
  - SCONE_CAS_ADDR=scone-cas.cf # __CAS_ONLY__
62
63
  - SCONE_LAS_ADDR=las # __CAS_ONLY__
@@ -39,9 +39,10 @@ services:
39
39
  - SCONE_CAS_ADDR=scone-cas.cf # __CAS_ONLY__
40
40
  - SCONE_LAS_ADDR=las # __CAS_ONLY__
41
41
  - SCONE_CONFIG_ID=__ENCLAVE_NAME_SECURELOCK__/application # __CAS_ONLY__
42
- - SCONE_HEAP=1024M
43
- # Must match the --stack the securelock binary was signed with (build.mjs),
44
- # otherwise SCONE recomputes MRENCLAVE at load and re-signs as debug.
42
+ # Must match the --heap and --stack the securelock binary was signed with
43
+ # (build.mjs), otherwise SCONE recomputes MRENCLAVE at load and re-signs
44
+ # as debug. The heap is the build's MEMORY_TO_ALLOCATE (.env).
45
+ - SCONE_HEAP=__MEMORY_TO_ALLOCATE__
45
46
  - SCONE_STACK=4M
46
47
  - SCONE_LOG=DEBUG
47
48
  - SCONE_ALLOW_DLOPEN=1
@@ -54,14 +55,14 @@ services:
54
55
  container_name: etny-trustedzone
55
56
  privileged: true
56
57
  image: localhost:5000/etny-trustedzone
57
- entrypoint: ""
58
- command: [ "/usr/local/bin/node", "/etny-trustedzone/trustedzone.js" ]
58
+ # No command: the image's ENTRYPOINT runs node with the flags the
59
+ # trustedzone was built and measured with (etny-nodenithy Dockerfile.tmpl).
59
60
  environment:
60
61
  - SCONE_CAS_ADDR=scone-cas.cf # __CAS_ONLY__
61
62
  - SCONE_LAS_ADDR=las # __CAS_ONLY__
62
63
  - SCONE_CONFIG_ID=__ENCLAVE_NAME_TRUSTEDZONE__/application # __CAS_ONLY__
63
- # The heap the trustedzone was measured and registered with: 1024M for
64
- # every testnet trustedzone (etny-nodenithy v3/run/docker-compose.yml).
64
+ # The heap the trustedzone was signed with: the SCONE_HEAP of the
65
+ # bundled trustedzone image (run.js reads it from the image).
65
66
  - SCONE_HEAP=__TRUSTEDZONE_HEAP__
66
67
  - SCONE_LOG=DEBUG
67
68
  - SCONE_ALLOW_DLOPEN=1
@@ -5,7 +5,8 @@
5
5
  // registries) is queued by the service's registry watcher from the
6
6
  // ImageRegistered event, with this publish's IPFS peer; the hash is posted to
7
7
  // /api/addHash only when that row has not appeared within
8
- // CHAIN_ROW_GRACE_SECONDS. Other images are posted at once.
8
+ // CHAIN_ROW_GRACE_SECONDS, or when the service holds a failed extraction of
9
+ // the image. Other images are posted at once.
9
10
  const axios = require('axios');
10
11
  const fs = require('fs');
11
12
  const { Command } = require('commander');
@@ -14,6 +15,14 @@ const BASE_URL = "https://publickey.ethernity.cloud";
14
15
  // The watcher polls the registries every 30 s.
15
16
  const CHAIN_ROW_GRACE_SECONDS = 180;
16
17
  const POLL_SECONDS = 10;
18
+ // A failed extraction is recorded against the hash with publicKey -1 and a
19
+ // reason, and a new submission of the hash runs it again. The service answers
20
+ // the old record for a moment after a submission, so a failed answer is taken
21
+ // as the outcome only once this long has passed since this publish submitted.
22
+ const RESUBMIT_GRACE_SECONDS = 60;
23
+
24
+ // The service's record of an extraction that failed.
25
+ const failed = (answer) => answer !== null && answer.publicKey === -1;
17
26
 
18
27
  async function submitIpfsHash(hash, enclaveName, protocolVersion, network, templateVersion, dockerComposerHash) {
19
28
  const url = `${BASE_URL}/api/addHash`;
@@ -48,9 +57,10 @@ async function checkIpfsHashStatus(hash) {
48
57
  }
49
58
  }
50
59
 
51
- // Queues the image as described above and returns the service's first
52
- // answer for it. `submit`, `check`, `sleep` and `now` are the functions
53
- // above and the clock, replaceable for tests.
60
+ // Queues the image as described above and returns the service's first answer
61
+ // for it (`answer`, null right after a submission) and whether this publish
62
+ // posted the hash (`submitted`). `submit`, `check`, `sleep` and `now` are the
63
+ // functions above and the clock, replaceable for tests.
54
64
  async function queueImage({ hash, chainRegistered, submitArgs, submit = submitIpfsHash, check = checkIpfsHashStatus,
55
65
  sleep = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000)), now = () => Date.now() / 1000,
56
66
  log = console.log, graceSeconds = CHAIN_ROW_GRACE_SECONDS, pollSeconds = POLL_SECONDS }) {
@@ -58,7 +68,11 @@ async function queueImage({ hash, chainRegistered, submitArgs, submit = submitIp
58
68
  const deadline = now() + graceSeconds;
59
69
  while (true) {
60
70
  const answer = await check(hash);
61
- if (answer !== null) return answer;
71
+ if (failed(answer)) {
72
+ log(`The service's previous extraction of this image failed (${answer.reason || "no reason reported"}); submitting the hash again`);
73
+ break;
74
+ }
75
+ if (answer !== null) return { answer, submitted: false };
62
76
  if (now() >= deadline) {
63
77
  log(`The certificate service has not picked the registration up from the chain in ${graceSeconds} s; submitting the hash`);
64
78
  break;
@@ -69,7 +83,7 @@ async function queueImage({ hash, chainRegistered, submitArgs, submit = submitIp
69
83
  }
70
84
  const submitResponse = await submit(hash, ...submitArgs);
71
85
  log("Submit IPFS Hash Response:", submitResponse);
72
- return null;
86
+ return { answer: null, submitted: true };
73
87
  }
74
88
 
75
89
  async function main() {
@@ -98,10 +112,12 @@ async function main() {
98
112
  console.log("Template Version:", templateVersion);
99
113
  console.log("Docker Composer Hash:", dockerComposerHash);
100
114
 
101
- let checkResponse = await queueImage({
115
+ const queued = await queueImage({
102
116
  hash: hhash, chainRegistered: Boolean(options.chainRegistered),
103
117
  submitArgs: [enclaveName, protocolVersion, network, templateVersion, dockerComposerHash],
104
118
  });
119
+ let checkResponse = queued.answer;
120
+ const submittedAt = queued.submitted ? Date.now() / 1000 : null;
105
121
 
106
122
  // The service answers publicKey 0 while the image is queued or running
107
123
  // (with status, percent and queuePosition), -1 when the extraction failed
@@ -119,10 +135,15 @@ async function main() {
119
135
  } else {
120
136
  console.log(`Public key not available yet. Queue position: ${checkResponse.queuePosition ?? 'Unknown'}`);
121
137
  }
122
- } else if (checkResponse.publicKey === -1) {
123
- console.log(`Public key extraction failed: ${checkResponse.reason || "reason not reported by the service"}`);
124
- console.log("Fix the cause (keep the image pinned, build with the current SDK) and publish again: a new submission of the same hash runs the extraction again.");
125
- process.exit(1);
138
+ } else if (failed(checkResponse)) {
139
+ if (submittedAt !== null && Date.now() / 1000 - submittedAt < RESUBMIT_GRACE_SECONDS) {
140
+ // The record of the run before this publish's submission.
141
+ console.log("The service still answers its previous record; waiting for the new run");
142
+ } else {
143
+ console.log(`Public key extraction failed: ${checkResponse.reason || "reason not reported by the service"}`);
144
+ console.log("Fix the cause (keep the image pinned, build with the current SDK) and publish again: a new submission of the same hash runs the extraction again.");
145
+ process.exit(1);
146
+ }
126
147
  } else {
127
148
  console.log("Public Key:", checkResponse.publicKey);
128
149
  // Save public key to file
@@ -152,4 +173,4 @@ async function main() {
152
173
 
153
174
  if (require.main === module) main();
154
175
 
155
- module.exports = { queueImage, CHAIN_ROW_GRACE_SECONDS, POLL_SECONDS };
176
+ module.exports = { queueImage, CHAIN_ROW_GRACE_SECONDS, POLL_SECONDS, RESUBMIT_GRACE_SECONDS };
package/nodenithy/run.js CHANGED
@@ -113,12 +113,28 @@ const extractSignedMrenclave = (service) => {
113
113
  }
114
114
  };
115
115
 
116
+ // The heap the trustedzone was signed with: the SCONE_HEAP of the image the
117
+ // build bundled (etny-nodenithy's Dockerfile sets it to the --heap it signs
118
+ // with). The compose must name the same value, or SCONE recomputes the
119
+ // measurement at load and the CAS refuses the enclave.
120
+ const trustedZoneHeapOfImage = (image) => {
121
+ const env = execSync(`docker image inspect ${image} --format "{{range .Config.Env}}{{println .}}{{end}}"`,
122
+ { stdio: ['pipe', 'pipe', 'pipe'] }).toString();
123
+ const m = env.match(/^SCONE_HEAP=(\S+)$/m);
124
+ if (!m) throw new Error(`${image} carries no SCONE_HEAP in its environment`);
125
+ return m[1];
126
+ };
127
+
116
128
  const main = async () => {
117
129
  process.env.NODE_NO_WARNINGS = 1
118
- // Every testnet trustedzone was measured and registered with a 1024M heap
119
- // (etny-nodenithy v3/run/docker-compose.yml). The mainnet session named
120
- // below comes from the features/v3 branches, which ran it with 256M.
121
- const trustedZoneHeap = isMainnet ? '256M' : '1024M';
130
+ const trustedZoneHeap = trustedZoneHeapOfImage('localhost:5000/etny-trustedzone');
131
+ console.log(`Trustedzone heap: ${trustedZoneHeap}`);
132
+ // The securelock heap ecld-build signed it with (part of MRENCLAVE).
133
+ const memory = (process.env.MEMORY_TO_ALLOCATE || '').trim();
134
+ if (!memory) {
135
+ console.error("Error: MEMORY_TO_ALLOCATE is not set in .env; run ecld-build first.");
136
+ process.exit(1);
137
+ }
122
138
 
123
139
  // Where the image goes. An IPFS API of the application's own
124
140
  // (IPFS_ENDPOINT, with IPFS_TOKEN as its Authorization header when set)
@@ -208,7 +224,8 @@ const main = async () => {
208
224
  return;
209
225
  }
210
226
  const content = casConfig.renderCompose(fs.readFileSync(template, 'utf8'), cas, unsafe)
211
- .replace(/__TRUSTEDZONE_HEAP__/g, trustedZoneHeap);
227
+ .replace(/__TRUSTEDZONE_HEAP__/g, trustedZoneHeap)
228
+ .replace(/__MEMORY_TO_ALLOCATE__/g, memory);
212
229
  fs.writeFileSync(file, content, 'utf8');
213
230
  });
214
231
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethernity-cloud/sdk-js",
3
- "version": "1.7.15",
3
+ "version": "1.7.20",
4
4
  "description": "A package to run ethernity cloud build scripts",
5
5
  "scripts": {
6
6
  "test": "node --test \"test/**/*.test.js\"",
@@ -40,7 +40,7 @@
40
40
  "!**/*.pyc"
41
41
  ],
42
42
  "dependencies": {
43
- "@ethernity-cloud/runner": "^0.6.11",
43
+ "@ethernity-cloud/runner": "^0.6.12",
44
44
  "acorn": "^8.11.0",
45
45
  "adm-zip": "^0.5.14",
46
46
  "axios": "^1.7.2",
@@ -46,6 +46,7 @@ import hashlib
46
46
  import io
47
47
  import json
48
48
  import os
49
+ import time
49
50
 
50
51
  DOMAIN_SEP_STATE = b"ethernity-cloud/esr-encryption/v1"
51
52
 
@@ -75,6 +76,11 @@ _task_ledger = {}
75
76
  # get_nonce() right after a commit in the SAME task returns the fresh value
76
77
  # even though the node's relay (and thus the chain) has not landed yet.
77
78
  _task_nonces = {}
79
+ # Per-task memo of this task's last commit per key, (acl, data, version, cid)
80
+ # as the chain shows them once the node's relay lands. A read prefers it while
81
+ # the chain still shows an older version, so get() after commit() returns the
82
+ # committed state and a second commit to the key builds on the first.
83
+ _task_writes = {}
78
84
  # Per-task memo of the NEXT relay nonce per key hash (hex), so multiple
79
85
  # commits to the same key within one task sign sequential relay nonces even
80
86
  # while the node is still relaying the earlier ones.
@@ -312,7 +318,7 @@ def configure(identity_priv, swift_stream_service, bucket, contract_address,
312
318
  value from the client payload or the node's .env.
313
319
  """
314
320
  global _identity_priv, _swift, _bucket, _contract_address, _web3, _esr_evidence
315
- global _task_ledger, _task_nonces, _task_relay_nonces, _task_caller
321
+ global _task_ledger, _task_nonces, _task_writes, _task_relay_nonces, _task_caller
316
322
  _identity_priv = identity_priv
317
323
  _swift = swift_stream_service
318
324
  _bucket = bucket
@@ -320,6 +326,7 @@ def configure(identity_priv, swift_stream_service, bucket, contract_address,
320
326
  _web3 = web3
321
327
  _task_ledger = {}
322
328
  _task_nonces = {}
329
+ _task_writes = {}
323
330
  _task_relay_nonces = {}
324
331
  _esr_evidence = []
325
332
  _task_caller = _norm_addr(caller)
@@ -357,6 +364,13 @@ class StatePermissionError(RuntimeError):
357
364
  # 0x00 = 1 commit ... 0xFF = 256; an all-zero root means no commits at all).
358
365
  MAX_COMMITS_PER_TASK = 256
359
366
 
367
+ # How long a read waits for the node to stage a state object the chain names
368
+ # before the task fails, and how often it looks for it: the node writes the
369
+ # objects pinned on it within seconds of the order's start and the ones it
370
+ # has to fetch from the network as they arrive.
371
+ STATE_STAGE_WAIT_SECONDS = 120
372
+ STATE_STAGE_POLL_SECONDS = 2
373
+
360
374
 
361
375
  class StateLimitError(RuntimeError):
362
376
  """Raised when a task tries to make more than MAX_COMMITS_PER_TASK state
@@ -478,15 +492,77 @@ def looks_like_cid(value) -> bool:
478
492
  return False
479
493
 
480
494
 
481
- def cidv1_raw(content: bytes) -> str:
482
- """CIDv1/raw/sha2-256 for `content`, computed without touching IPFS.
495
+ # A state object is stored as the node's Kubo stores it with `add
496
+ # --cid-version=1 --raw-leaves` and its default chunker: in raw sha256 blocks
497
+ # of BLOCK_SIZE bytes (the last one shorter), a single block as it is, several
498
+ # under a balanced UnixFS tree of at most LINKS_PER_NODE blocks per node.
499
+ # unixfs_cid() computes the resulting CID from the bytes, so the enclave
500
+ # commits a CID it derived itself rather than one a node handed it, and the
501
+ # node stores the object under that CID (mvp-pox-node utils.py unixfs_cid).
502
+ BLOCK_SIZE = 262144
503
+ LINKS_PER_NODE = 174
483
504
 
484
- Matches `ipfs add --cid-version=1 --raw-leaves`, which is what lets the
485
- enclave commit a CID it derived itself rather than one a node handed it.
486
- """
487
- digest = hashlib.sha256(content).digest()
488
- raw = bytes([0x01, 0x55, 0x12, 0x20]) + digest
489
- return "b" + base64.b32encode(raw).decode("ascii").lower().rstrip("=")
505
+
506
+ def _cid_b32(cid: bytes) -> str:
507
+ return "b" + base64.b32encode(cid).decode("ascii").lower().rstrip("=")
508
+
509
+
510
+ def _pb_varint(n: int) -> bytes:
511
+ out = bytearray()
512
+ while True:
513
+ byte = n & 0x7F
514
+ n >>= 7
515
+ if n:
516
+ out.append(byte | 0x80)
517
+ else:
518
+ out.append(byte)
519
+ return bytes(out)
520
+
521
+
522
+ def _pb_field(number: int, wire: int, payload: bytes) -> bytes:
523
+ """A protobuf field: varint (wire 0) or length-delimited (wire 2)."""
524
+ tag = _pb_varint((number << 3) | wire)
525
+ return tag + (_pb_varint(len(payload)) + payload if wire == 2 else payload)
526
+
527
+
528
+ def _pb_raw_cid(content: bytes) -> bytes:
529
+ """CIDv1, codec raw (0x55), sha2-256 (0x12, 32 bytes)."""
530
+ return bytes([0x01, 0x55, 0x12, 0x20]) + hashlib.sha256(content).digest()
531
+
532
+
533
+ def _pb_file_node(children):
534
+ """A dag-pb UnixFS File node over `children`, each (cid, tsize, filesize);
535
+ returns (cid, tsize, filesize). Links come before Data in dag-pb; a link
536
+ carries the child's CID, an empty Name and its Tsize, the size of the
537
+ child's subtree; Data is UnixFS Type=File(2), filesize, blocksizes."""
538
+ links = b"".join(
539
+ _pb_field(2, 2, _pb_field(1, 2, cid) + _pb_field(2, 2, b"") + _pb_field(3, 0, _pb_varint(tsize)))
540
+ for cid, tsize, _ in children)
541
+ filesize = sum(size for _, _, size in children)
542
+ data = _pb_field(1, 0, _pb_varint(2)) + _pb_field(3, 0, _pb_varint(filesize))
543
+ data += b"".join(_pb_field(4, 0, _pb_varint(size)) for _, _, size in children)
544
+ block = links + _pb_field(1, 2, data)
545
+ cid = bytes([0x01, 0x70, 0x12, 0x20]) + hashlib.sha256(block).digest()
546
+ return cid, len(block) + sum(tsize for _, tsize, _ in children), filesize
547
+
548
+
549
+ def unixfs_cid(content: bytes, block_size: int = BLOCK_SIZE) -> str:
550
+ """The CID the node's Kubo gives `content`: the raw block's CID up to one
551
+ block, else the root of the balanced UnixFS tree over the blocks,
552
+ LINKS_PER_NODE to a node, every node full but the last."""
553
+ if len(content) <= block_size:
554
+ return _cid_b32(_pb_raw_cid(content))
555
+ nodes = [(_pb_raw_cid(content[i:i + block_size]), len(content[i:i + block_size]), len(content[i:i + block_size]))
556
+ for i in range(0, len(content), block_size)]
557
+ while len(nodes) > 1:
558
+ nodes = [_pb_file_node(nodes[i:i + LINKS_PER_NODE]) for i in range(0, len(nodes), LINKS_PER_NODE)]
559
+ return _cid_b32(nodes[0][0])
560
+
561
+
562
+ def cidv1_raw(content: bytes) -> str:
563
+ """CIDv1/raw/sha2-256 for `content`: its CID as one raw block, which is
564
+ its CID through the node's Kubo up to BLOCK_SIZE bytes."""
565
+ return _cid_b32(_pb_raw_cid(content))
490
566
 
491
567
 
492
568
  # AES-GCM nonce length, pinned explicitly. PyCryptodome defaults to 16 bytes
@@ -601,6 +677,9 @@ class StateRegistry:
601
677
  contract = self._contract()
602
678
  cid, version, _updated = contract.functions.getState(
603
679
  self.wallet_address, _key_hash(key)).call()
680
+ own = _task_writes.get(key)
681
+ if own is not None and own[2] > int(version or 0):
682
+ return own
604
683
  if not version or not cid:
605
684
  return None, default, 0, None
606
685
  if not looks_like_cid(cid):
@@ -747,7 +826,7 @@ class StateRegistry:
747
826
 
748
827
  blob = _encrypt(json.dumps(stored, separators=(",", ":")).encode("utf-8"))
749
828
  # Compute the CID from OUR bytes -- never accept one from the node.
750
- cid = cidv1_raw(blob)
829
+ cid = unixfs_cid(blob)
751
830
 
752
831
  # Hand the blob and the CID to the node for pinning. It verifies the
753
832
  # CID matches; it does not get to choose it. Fire-and-forget: the
@@ -766,6 +845,7 @@ class StateRegistry:
766
845
  # The ledger records the DATA -- the ACL never leaves in results.
767
846
  _ledger_record(key, current_version + 1, cid, new_data)
768
847
  _task_nonces[key] = final_nonce
848
+ _task_writes[key] = (new_acl, new_data, current_version + 1, cid)
769
849
  return new_acl, new_data
770
850
  except Exception as e:
771
851
  # A version race means someone else committed first: re-read and
@@ -805,17 +885,34 @@ class StateRegistry:
805
885
  delivery path -- but not a trusted one: the content is checked against
806
886
  the CID the chain records, so a substituted or corrupted blob is
807
887
  rejected rather than decrypted and returned as state.
888
+
889
+ The node stages it as state.<cid>.enc, within seconds of the order's
890
+ start when the object is pinned on it and as it arrives when it has
891
+ to fetch it from the network, so the object is looked for every
892
+ STATE_STAGE_POLL_SECONDS for up to STATE_STAGE_WAIT_SECONDS.
893
+ state.<key>.enc is the last blob a commit of this key published,
894
+ which matches only while no later commit was published; one that
895
+ does not match is passed over.
808
896
  """
809
- candidates = (f"state.{key}.enc", f"state.{cid}.enc")
810
- for name in candidates:
811
- ok, data = _swift.get_file_content_bytes(_bucket, name)
812
- if ok and data:
813
- if cidv1_raw(data) != cid:
814
- raise RuntimeError(
815
- f"State object {name} does not match the committed CID "
816
- f"{cid} -- refusing to use it")
817
- return data
818
- raise RuntimeError(f"Could not read state object for '{key}' ({cid})")
897
+ deadline = time.monotonic() + STATE_STAGE_WAIT_SECONDS
898
+ while True:
899
+ mismatched = []
900
+ for name in (f"state.{cid}.enc", f"state.{key}.enc"):
901
+ ok, data = _swift.get_file_content_bytes(_bucket, name)
902
+ if ok and data:
903
+ if unixfs_cid(data) == cid:
904
+ return data
905
+ mismatched.append(name)
906
+ if time.monotonic() >= deadline:
907
+ break
908
+ time.sleep(STATE_STAGE_POLL_SECONDS)
909
+ if mismatched:
910
+ raise RuntimeError(
911
+ f"State object {' and '.join(mismatched)} does not match the "
912
+ f"committed CID {cid} -- refusing to use it")
913
+ raise RuntimeError(
914
+ f"Could not read state object for '{key}' ({cid}): not staged "
915
+ f"within {STATE_STAGE_WAIT_SECONDS} s")
819
916
 
820
917
  def _send_commit(self, key_hash: bytes, cid: str, expected_version: int,
821
918
  owner_addr: str = None, nonce: int = 0):
package/pynithy/build.mjs CHANGED
@@ -156,7 +156,7 @@ const casProvisioned = casConfig.casProvisioned(templateName, isMainnet);
156
156
 
157
157
  // Enclave heap (SCONE_HEAP). Part of the measurement: publish runs the
158
158
  // securelock with the value recorded here.
159
- const MEMORY_TO_ALLOCATE = (process.env.ECLD_MEMORY_TO_ALLOCATE || process.env.MEMORY_TO_ALLOCATE || '1024M').trim();
159
+ const MEMORY_TO_ALLOCATE = (process.env.ECLD_MEMORY_TO_ALLOCATE || process.env.MEMORY_TO_ALLOCATE || '256M').trim();
160
160
  writeEnv('MEMORY_TO_ALLOCATE', MEMORY_TO_ALLOCATE);
161
161
 
162
162
  runCommand('docker pull registry:2');
@@ -5,7 +5,8 @@
5
5
  // registries) is queued by the service's registry watcher from the
6
6
  // ImageRegistered event, with this publish's IPFS peer; the hash is posted to
7
7
  // /api/addHash only when that row has not appeared within
8
- // CHAIN_ROW_GRACE_SECONDS. Other images are posted at once.
8
+ // CHAIN_ROW_GRACE_SECONDS, or when the service holds a failed extraction of
9
+ // the image. Other images are posted at once.
9
10
  const axios = require('axios');
10
11
  const fs = require('fs');
11
12
  const { Command } = require('commander');
@@ -14,6 +15,14 @@ const BASE_URL = "https://publickey.ethernity.cloud";
14
15
  // The watcher polls the registries every 30 s.
15
16
  const CHAIN_ROW_GRACE_SECONDS = 180;
16
17
  const POLL_SECONDS = 10;
18
+ // A failed extraction is recorded against the hash with publicKey -1 and a
19
+ // reason, and a new submission of the hash runs it again. The service answers
20
+ // the old record for a moment after a submission, so a failed answer is taken
21
+ // as the outcome only once this long has passed since this publish submitted.
22
+ const RESUBMIT_GRACE_SECONDS = 60;
23
+
24
+ // The service's record of an extraction that failed.
25
+ const failed = (answer) => answer !== null && answer.publicKey === -1;
17
26
 
18
27
  async function submitIpfsHash(hash, enclaveName, protocolVersion, network, templateVersion, dockerComposerHash) {
19
28
  const url = `${BASE_URL}/api/addHash`;
@@ -48,9 +57,10 @@ async function checkIpfsHashStatus(hash) {
48
57
  }
49
58
  }
50
59
 
51
- // Queues the image as described above and returns the service's first
52
- // answer for it. `submit`, `check`, `sleep` and `now` are the functions
53
- // above and the clock, replaceable for tests.
60
+ // Queues the image as described above and returns the service's first answer
61
+ // for it (`answer`, null right after a submission) and whether this publish
62
+ // posted the hash (`submitted`). `submit`, `check`, `sleep` and `now` are the
63
+ // functions above and the clock, replaceable for tests.
54
64
  async function queueImage({ hash, chainRegistered, submitArgs, submit = submitIpfsHash, check = checkIpfsHashStatus,
55
65
  sleep = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000)), now = () => Date.now() / 1000,
56
66
  log = console.log, graceSeconds = CHAIN_ROW_GRACE_SECONDS, pollSeconds = POLL_SECONDS }) {
@@ -58,7 +68,11 @@ async function queueImage({ hash, chainRegistered, submitArgs, submit = submitIp
58
68
  const deadline = now() + graceSeconds;
59
69
  while (true) {
60
70
  const answer = await check(hash);
61
- if (answer !== null) return answer;
71
+ if (failed(answer)) {
72
+ log(`The service's previous extraction of this image failed (${answer.reason || "no reason reported"}); submitting the hash again`);
73
+ break;
74
+ }
75
+ if (answer !== null) return { answer, submitted: false };
62
76
  if (now() >= deadline) {
63
77
  log(`The certificate service has not picked the registration up from the chain in ${graceSeconds} s; submitting the hash`);
64
78
  break;
@@ -69,7 +83,7 @@ async function queueImage({ hash, chainRegistered, submitArgs, submit = submitIp
69
83
  }
70
84
  const submitResponse = await submit(hash, ...submitArgs);
71
85
  log("Submit IPFS Hash Response:", submitResponse);
72
- return null;
86
+ return { answer: null, submitted: true };
73
87
  }
74
88
 
75
89
  async function main() {
@@ -98,10 +112,12 @@ async function main() {
98
112
  console.log("Template Version:", templateVersion);
99
113
  console.log("Docker Composer Hash:", dockerComposerHash);
100
114
 
101
- let checkResponse = await queueImage({
115
+ const queued = await queueImage({
102
116
  hash: hhash, chainRegistered: Boolean(options.chainRegistered),
103
117
  submitArgs: [enclaveName, protocolVersion, network, templateVersion, dockerComposerHash],
104
118
  });
119
+ let checkResponse = queued.answer;
120
+ const submittedAt = queued.submitted ? Date.now() / 1000 : null;
105
121
 
106
122
  // The service answers publicKey 0 while the image is queued or running
107
123
  // (with status, percent and queuePosition), -1 when the extraction failed
@@ -119,10 +135,15 @@ async function main() {
119
135
  } else {
120
136
  console.log(`Public key not available yet. Queue position: ${checkResponse.queuePosition ?? 'Unknown'}`);
121
137
  }
122
- } else if (checkResponse.publicKey === -1) {
123
- console.log(`Public key extraction failed: ${checkResponse.reason || "reason not reported by the service"}`);
124
- console.log("Fix the cause (keep the image pinned, build with the current SDK) and publish again: a new submission of the same hash runs the extraction again.");
125
- process.exit(1);
138
+ } else if (failed(checkResponse)) {
139
+ if (submittedAt !== null && Date.now() / 1000 - submittedAt < RESUBMIT_GRACE_SECONDS) {
140
+ // The record of the run before this publish's submission.
141
+ console.log("The service still answers its previous record; waiting for the new run");
142
+ } else {
143
+ console.log(`Public key extraction failed: ${checkResponse.reason || "reason not reported by the service"}`);
144
+ console.log("Fix the cause (keep the image pinned, build with the current SDK) and publish again: a new submission of the same hash runs the extraction again.");
145
+ process.exit(1);
146
+ }
126
147
  } else {
127
148
  console.log("Public Key:", checkResponse.publicKey);
128
149
  // Save public key to file
@@ -152,4 +173,4 @@ async function main() {
152
173
 
153
174
  if (require.main === module) main();
154
175
 
155
- module.exports = { queueImage, CHAIN_ROW_GRACE_SECONDS, POLL_SECONDS };
176
+ module.exports = { queueImage, CHAIN_ROW_GRACE_SECONDS, POLL_SECONDS, RESUBMIT_GRACE_SECONDS };
package/test.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  /* ecld-test — run a Nodenithy payload locally, without SGX, exactly as the
3
3
  * enclave would.
4
4
  *