@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 +2 -2
- package/nodenithy/build/securelock/Dockerfile.tmpl +3 -2
- package/nodenithy/build/securelock/src/ecld_state.js +127 -18
- package/nodenithy/build.mjs +6 -0
- package/nodenithy/run/docker-compose-final.yml.tmpl +6 -5
- package/nodenithy/run/docker-compose.yml.tmpl +8 -7
- package/nodenithy/run/public_key_service.js +33 -12
- package/nodenithy/run.js +22 -5
- package/package.json +2 -2
- package/pynithy/build/securelock/src/ecld_state.py +117 -20
- package/pynithy/build.mjs +1 -1
- package/pynithy/run/public_key_service.js +33 -12
- package/test.js +1 -1
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 (`
|
|
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 (`
|
|
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
|
|
67
|
-
|
|
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
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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,
|
package/nodenithy/build.mjs
CHANGED
|
@@ -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
|
-
|
|
40
|
-
#
|
|
41
|
-
#
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
43
|
-
#
|
|
44
|
-
#
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
64
|
-
#
|
|
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
|
|
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
|
-
//
|
|
53
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
const
|
|
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.
|
|
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.
|
|
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
|
-
|
|
482
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
"""
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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 =
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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 || '
|
|
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
|
|
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
|
-
//
|
|
53
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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