@ethernity-cloud/sdk-js 1.2.0 → 1.5.0
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/init.js +49 -0
- package/nodenithy/build/securelock/Dockerfile.tmpl +5 -0
- package/nodenithy/build/securelock/src/README-keygen.md +41 -0
- package/nodenithy/build/securelock/src/ecld_state.js +304 -0
- package/nodenithy/build/securelock/src/esr_wallet.js +147 -0
- package/nodenithy/build/securelock/src/etny_crypto.js +211 -201
- package/nodenithy/build/securelock/src/etny_exec.js +68 -18
- package/nodenithy/build/securelock/src/models.js +275 -261
- package/nodenithy/build/securelock/src/securelock.js.tmpl +547 -421
- package/nodenithy/build/securelock/src/self_sign.js +76 -0
- package/nodenithy/build/securelock/src/sgx_report.js +50 -0
- package/nodenithy/build/securelock/src/sgx_report.node +0 -0
- package/nodenithy/build/securelock/src/task_status.js +7 -0
- package/nodenithy/build.js +22 -0
- package/nodenithy/build.mjs +55 -1
- package/nodenithy/run/public_key_service.js +11 -0
- package/nodenithy/run.js +22 -0
- package/package.json +6 -4
- package/test.js +204 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Testnet non-CAS self-sign: derive a DETERMINISTIC secp384r1 key + self-signed
|
|
2
|
+
// X509 from the enclave's own MR_ENCLAVE / MR_SIGNER (read via the sgx_report
|
|
3
|
+
// N-API addon). Only the exact attested enclave can regenerate this key, so the
|
|
4
|
+
// harvested cert (registered on-chain) survives restarts and stays consistent.
|
|
5
|
+
// [TESTNET-INSECURE] -- testnet runs pre-release SGX; NOT a production guarantee.
|
|
6
|
+
//
|
|
7
|
+
// Cert-compatibility note: nodenithy's trustedzone fetches the securelock public
|
|
8
|
+
// key from the on-chain image registry (getImageCertPublicKey) and does NOT
|
|
9
|
+
// re-derive it from MR_ENCLAVE. Both enclaves are Node. So this cert only needs
|
|
10
|
+
// to be a valid P-384 X509 that is STABLE per MR_ENCLAVE and readable by
|
|
11
|
+
// etny_crypto (jsrsasign KEYUTIL for the key, crypto.createPublicKey/ec-key for
|
|
12
|
+
// the cert). It does NOT need to be byte-identical to pynithy's Python DER.
|
|
13
|
+
const rs = require('jsrsasign');
|
|
14
|
+
const sgx = require('./sgx_report');
|
|
15
|
+
|
|
16
|
+
// The identity-key derivation lives ENTIRELY in the compiled addon
|
|
17
|
+
// (get_sgx_report.c, via sgx_report.deriveIdentityScalar). It is not present in
|
|
18
|
+
// this interpreted source, so reading self_sign.js reveals nothing about how
|
|
19
|
+
// the key is derived. Each enclave uses its own role tag inside the addon, so
|
|
20
|
+
// the derivations are mutually decoupled. role: 0=securelock 1=trustedzone
|
|
21
|
+
// 2=validator, selected from the subject CN.
|
|
22
|
+
function roleForSubject(subjectCN) {
|
|
23
|
+
const cn = String(subjectCN || '').toLowerCase();
|
|
24
|
+
if (cn.includes('securelock')) return 0;
|
|
25
|
+
if (cn.includes('validator')) return 2;
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generate the deterministic self-signed cert for this enclave.
|
|
31
|
+
* @param {string} subjectCN e.g. "SecureLock Enclave" / "TrustedZone Enclave [TESTNET-INSECURE]"
|
|
32
|
+
* @returns {{privateKeyPem: string, certPem: string, mrEnclaveHex: string}}
|
|
33
|
+
*/
|
|
34
|
+
function generateCertFromMrEnclave(subjectCN) {
|
|
35
|
+
const mrEnclaveHex = sgx.getMrEnclave(); // 64 hex chars (32 bytes)
|
|
36
|
+
|
|
37
|
+
// Derivation performed inside the compiled addon; role from the subject CN.
|
|
38
|
+
const privHex = sgx.deriveIdentityScalar(roleForSubject(subjectCN));
|
|
39
|
+
|
|
40
|
+
// Build the EC keypair from the deterministic private scalar (jsrsasign).
|
|
41
|
+
const ecKeypair = rs.KEYUTIL.generateKeypair('EC', 'secp384r1');
|
|
42
|
+
const prvKey = new rs.KJUR.crypto.ECDSA({ curve: 'secp384r1' });
|
|
43
|
+
prvKey.setPrivateKeyHex(privHex);
|
|
44
|
+
prvKey.generatePublicKeyHex(); // derive pub from priv
|
|
45
|
+
void ecKeypair;
|
|
46
|
+
|
|
47
|
+
const pubKey = new rs.KJUR.crypto.ECDSA({ curve: 'secp384r1' });
|
|
48
|
+
pubKey.setPublicKeyHex(prvKey.pubKeyHex);
|
|
49
|
+
|
|
50
|
+
// Self-signed X509: fixed serial/validity so the cert is stable per key. No
|
|
51
|
+
// custom MR_ENCLAVE extension -- unlike pynithy (whose cert carries it as an
|
|
52
|
+
// informational OID), nothing in nodenithy reads it: the trustedzone uses the
|
|
53
|
+
// securelock public key fetched from the on-chain image registry, not the cert
|
|
54
|
+
// extensions. Keeping the cert minimal avoids jsrsasign generic-extension API
|
|
55
|
+
// fragility across versions. mrEnclaveHex is still returned for logging.
|
|
56
|
+
const cert = new rs.KJUR.asn1.x509.Certificate({
|
|
57
|
+
version: 3,
|
|
58
|
+
serial: { int: 31337 },
|
|
59
|
+
issuer: { str: '/CN=' + subjectCN },
|
|
60
|
+
subject: { str: '/CN=' + subjectCN },
|
|
61
|
+
notbefore: '250101000000Z',
|
|
62
|
+
notafter: '350101000000Z',
|
|
63
|
+
sbjpubkey: pubKey,
|
|
64
|
+
sigalg: 'SHA256withECDSA',
|
|
65
|
+
cakey: prvKey,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const certPem = cert.getPEM();
|
|
69
|
+
|
|
70
|
+
// Private key as PKCS8 PEM (etny_crypto reads it via rs.KEYUTIL.getKey().prvKeyHex).
|
|
71
|
+
const privateKeyPem = rs.KEYUTIL.getPEM(prvKey, 'PKCS8PRV');
|
|
72
|
+
|
|
73
|
+
return { privateKeyPem, certPem, mrEnclaveHex };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { generateCertFromMrEnclave };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Loader for the SGX EREPORT N-API addon (sgx_report.node), which exposes the
|
|
2
|
+
// enclave's own MR_ENCLAVE / MR_SIGNER (used to deterministically derive the
|
|
3
|
+
// testnet self-signed cert). The addon is compiled from sgx_report_napi.c +
|
|
4
|
+
// get_sgx_report.c at build time and baked into binary-fs (see the Dockerfile).
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
let addon = null;
|
|
8
|
+
function load() {
|
|
9
|
+
if (addon) return addon;
|
|
10
|
+
// The addon sits next to the enclave sources in the image; try a couple of
|
|
11
|
+
// resolutions so it works in the enclave (/etny-securelock) and in local test.
|
|
12
|
+
const candidates = [
|
|
13
|
+
path.join(__dirname, 'sgx_report.node'),
|
|
14
|
+
'/etny-trustedzone/sgx_report.node',
|
|
15
|
+
'./sgx_report.node',
|
|
16
|
+
];
|
|
17
|
+
let lastErr;
|
|
18
|
+
for (const p of candidates) {
|
|
19
|
+
try {
|
|
20
|
+
addon = require(p);
|
|
21
|
+
return addon;
|
|
22
|
+
} catch (e) {
|
|
23
|
+
lastErr = e;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
throw new Error('Failed to load sgx_report.node addon: ' + (lastErr && lastErr.message));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getMrEnclave() {
|
|
30
|
+
const v = load().getMrEnclave();
|
|
31
|
+
if (!v) throw new Error('get_mr_enclave() returned null (EREPORT failed)');
|
|
32
|
+
return v;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getMrSigner() {
|
|
36
|
+
const v = load().getMrSigner();
|
|
37
|
+
if (!v) throw new Error('get_mr_signer() returned null (EREPORT failed)');
|
|
38
|
+
return v;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Consolidated identity-key derivation, performed inside the compiled addon
|
|
42
|
+
// (get_sgx_report.c). role: 0=securelock, 1=trustedzone, 2=validator.
|
|
43
|
+
// Returns the 96-hex secp384r1 scalar. The algorithm is NOT in self_sign.js.
|
|
44
|
+
function deriveIdentityScalar(role) {
|
|
45
|
+
const v = load().deriveIdentityScalar(role | 0);
|
|
46
|
+
if (!v) throw new Error('deriveIdentityScalar() returned null');
|
|
47
|
+
return v;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { getMrEnclave, getMrSigner, deriveIdentityScalar };
|
|
Binary file
|
|
@@ -8,6 +8,13 @@ class TaskStatus {
|
|
|
8
8
|
static PAYLOAD_CHECKSUM_ERROR = 6;
|
|
9
9
|
static INPUT_CHECKSUM_ERROR = 7;
|
|
10
10
|
static EVAL_ERROR = 8;
|
|
11
|
+
// Extended diagnostic (matches the trustedzone's extended enum): the
|
|
12
|
+
// serverless backend failed to load, so none of its functions exist.
|
|
13
|
+
static IMPORT_ERROR = 28;
|
|
14
|
+
// A required enclave config value is present but EMPTY (e.g. an ESR
|
|
15
|
+
// address that was never baked into the sealed image). Reported eagerly
|
|
16
|
+
// with the variable named, instead of a confusing downstream crash.
|
|
17
|
+
static CONFIG_ERROR = 32;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
module.exports = {
|
package/nodenithy/build.js
CHANGED
|
@@ -67,6 +67,28 @@ runCommand(`docker rm registry -f`);
|
|
|
67
67
|
|
|
68
68
|
const srcDir = './src/serverless';
|
|
69
69
|
const destDir = path.join(buildDir, 'securelock/src/serverless');
|
|
70
|
+
|
|
71
|
+
// Fail the build NOW if the backend is missing or unparseable. An enclave
|
|
72
|
+
// built without a valid backend runs every task into "X is not defined" /
|
|
73
|
+
// IMPORT_ERROR on-chain — expensive to discover after building, publishing
|
|
74
|
+
// and paying for a task.
|
|
75
|
+
const backendFile = path.join(srcDir, 'backend.js');
|
|
76
|
+
if (!fs.existsSync(backendFile)) {
|
|
77
|
+
console.error(`ERROR: ${backendFile} not found.`);
|
|
78
|
+
console.error(' The securelock enclave loads your functions from src/serverless/backend.js;');
|
|
79
|
+
console.error(' without it no backend function will exist inside the enclave.');
|
|
80
|
+
console.error(' Run ecld-init to scaffold it, or create the file before building.');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
require(path.resolve(backendFile));
|
|
85
|
+
} catch (e) {
|
|
86
|
+
console.error('ERROR: src/serverless/backend.js failed to load and would fail inside the enclave:');
|
|
87
|
+
console.error(` ${e.name || 'Error'}: ${e.message}`);
|
|
88
|
+
console.error(' Fix the module (missing dependency? bad require?) before building.');
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
70
92
|
console.log(`Creating destination directory: ${destDir}`);
|
|
71
93
|
fs.mkdirSync(destDir, { recursive: true });
|
|
72
94
|
|
package/nodenithy/build.mjs
CHANGED
|
@@ -92,6 +92,29 @@ runCommand(`docker rm registry -f`);
|
|
|
92
92
|
|
|
93
93
|
const srcDir = './src/serverless';
|
|
94
94
|
const destDir = path.join(buildDir, 'securelock/src/serverless');
|
|
95
|
+
|
|
96
|
+
// Fail the build NOW if the backend is missing or unloadable. An enclave
|
|
97
|
+
// built without a valid backend runs every task into "X is not defined" /
|
|
98
|
+
// IMPORT_ERROR on-chain — expensive to discover after building, publishing
|
|
99
|
+
// and paying for a task.
|
|
100
|
+
const backendFile = path.join(srcDir, 'backend.js');
|
|
101
|
+
if (!fs.existsSync(backendFile)) {
|
|
102
|
+
console.error(`ERROR: ${backendFile} not found.`);
|
|
103
|
+
console.error(' The securelock enclave loads your functions from src/serverless/backend.js;');
|
|
104
|
+
console.error(' without it no backend function will exist inside the enclave.');
|
|
105
|
+
console.error(' Run ecld-init to scaffold it, or create the file before building.');
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const { createRequire } = await import('module');
|
|
110
|
+
createRequire(import.meta.url)(path.resolve(backendFile));
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.error('ERROR: src/serverless/backend.js failed to load and would fail inside the enclave:');
|
|
113
|
+
console.error(` ${e.name || 'Error'}: ${e.message}`);
|
|
114
|
+
console.error(' Fix the module (missing dependency? bad require?) before building.');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
95
118
|
console.log(`Creating destination directory: ${destDir}`);
|
|
96
119
|
fs.mkdirSync(destDir, { recursive: true });
|
|
97
120
|
|
|
@@ -120,9 +143,40 @@ writeEnv('ENCLAVE_NAME_SECURELOCK', ENCLAVE_NAME_SECURELOCK);
|
|
|
120
143
|
|
|
121
144
|
console.log('Building etny-securelock');
|
|
122
145
|
process.chdir('securelock');
|
|
146
|
+
|
|
147
|
+
// The SGX key-gen module ships as a PREBUILT sgx_report.node (never the .c
|
|
148
|
+
// source). It is a build artifact of the etny-nodenithy pipeline; the
|
|
149
|
+
// securelock Dockerfile bakes it into binary-fs but does not compile it. Fail
|
|
150
|
+
// fast with a clear message if it is missing, rather than producing an enclave
|
|
151
|
+
// that crashes on the missing addon at runtime.
|
|
152
|
+
if (!fs.existsSync('src/sgx_report.node')) {
|
|
153
|
+
console.error('ERROR: securelock/src/sgx_report.node not found.');
|
|
154
|
+
console.error(' The SDK ships the SGX key-gen module as a prebuilt .node, not source.');
|
|
155
|
+
console.error(' It is a build artifact of the etny-nodenithy pipeline; copy it into');
|
|
156
|
+
console.error(' securelock/src/ (see src/README-keygen.md) before building.');
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
// ESR fail-fast gate (RFC §5.4), mirroring the Python SDK's validate_esr_config:
|
|
160
|
+
// never build an ESR-enabled enclave with an unresolved registry address. The
|
|
161
|
+
// enclave is SEALED -- a missing value bakes in as empty, and every task then
|
|
162
|
+
// fails at runtime after gas is already spent. ESR is opt-in: absent/false
|
|
163
|
+
// ESR_ENABLED means the build behaves exactly as it did before ESR existed.
|
|
164
|
+
const ESR_ENABLED = /^(1|true|yes)$/i.test(String(process.env.ESR_ENABLED || '').trim());
|
|
165
|
+
const ESR_CONTRACT_ADDRESS = String(process.env.ESR_CONTRACT_ADDRESS || '').trim();
|
|
166
|
+
if (ESR_ENABLED && !/^0x[0-9a-fA-F]{40}$/.test(ESR_CONTRACT_ADDRESS)) {
|
|
167
|
+
console.error('ERROR: ESR is enabled but ESR_CONTRACT_ADDRESS is not a valid address'
|
|
168
|
+
+ ` (got ${JSON.stringify(ESR_CONTRACT_ADDRESS)}).`);
|
|
169
|
+
console.error(' The enclave is sealed: a missing value bakes in as EMPTY and every');
|
|
170
|
+
console.error(' task fails at runtime after gas is spent. Set it with:');
|
|
171
|
+
console.error(' ecld-init (ESR step) or ESR_CONTRACT_ADDRESS in .env');
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
// Rendered only when enabled, so non-ESR images keep byte-identical layers.
|
|
175
|
+
const esrEnvBlock = ESR_ENABLED ? `ENV ESR_CONTRACT_ADDRESS=${ESR_CONTRACT_ADDRESS}` : '';
|
|
176
|
+
|
|
123
177
|
// runCommand(`cat Dockerfile.tmpl | sed s/"__ENCLAVE_NAME_SECURELOCK__"/"${ENCLAVE_NAME_SECURELOCK}"/g > Dockerfile`);
|
|
124
178
|
const dockerfileSecureTemplate = fs.readFileSync('Dockerfile.tmpl', 'utf8');
|
|
125
|
-
let dockerfileSecureContent = dockerfileSecureTemplate.replace(/__ENCLAVE_NAME_SECURELOCK__/g, ENCLAVE_NAME_SECURELOCK).replace(/__BUCKET_NAME__/g, templateName + "-v3").replace(/__SMART_CONTRACT_ADDRESS__/g, ECRunner[templateName][0]).replace(/__IMAGE_REGISTRY_ADDRESS__/g, ECRunner[templateName][1]).replace(/__RPC_URL__/g, ECRunner[templateName][2]).replace(/__CHAIN_ID__/g, ECRunner[templateName][3]).replace(/__TRUSTED_ZONE_IMAGE__/g, templateName);
|
|
179
|
+
let dockerfileSecureContent = dockerfileSecureTemplate.replace(/__ENCLAVE_NAME_SECURELOCK__/g, ENCLAVE_NAME_SECURELOCK).replace(/__BUCKET_NAME__/g, templateName + "-v3").replace(/__SMART_CONTRACT_ADDRESS__/g, ECRunner[templateName][0]).replace(/__IMAGE_REGISTRY_ADDRESS__/g, ECRunner[templateName][1]).replace(/__RPC_URL__/g, ECRunner[templateName][2]).replace(/__CHAIN_ID__/g, ECRunner[templateName][3]).replace(/__TRUSTED_ZONE_IMAGE__/g, templateName).replace(/^__ESR_ENV__\n/m, esrEnvBlock ? `${esrEnvBlock}\n` : '');
|
|
126
180
|
|
|
127
181
|
// Amount of enclave heap to allocate (SCONE_HEAP). Kept in sync with the
|
|
128
182
|
// run/docker-compose securelock service; overridable via ECLD_MEMORY_TO_ALLOCATE.
|
|
@@ -78,6 +78,17 @@ console.log("Docker Composer Hash:", dockerComposerHash);
|
|
|
78
78
|
console.log("Public Key:", checkResponse.publicKey);
|
|
79
79
|
// Save public key to file
|
|
80
80
|
fs.writeFileSync('PUBLIC_KEY.txt', checkResponse.publicKey);
|
|
81
|
+
// ESR (RFC §5.2): the enclave also prints ESR_WALLET_ADDRESS on the
|
|
82
|
+
// cert channel. The service currently returns only "publicKey"; when
|
|
83
|
+
// it starts relaying the enclave's output (or adds an
|
|
84
|
+
// "esrWalletAddress" field) it is picked up here and written to a
|
|
85
|
+
// sibling file the caller reads -- no other changes needed. Until
|
|
86
|
+
// then, ESR projects extract locally on an SGX host once to learn
|
|
87
|
+
// the address (publish then persists it).
|
|
88
|
+
if (checkResponse.esrWalletAddress) {
|
|
89
|
+
console.log("ESR Wallet Address:", checkResponse.esrWalletAddress);
|
|
90
|
+
fs.writeFileSync('ESR_WALLET_ADDRESS.txt', checkResponse.esrWalletAddress);
|
|
91
|
+
}
|
|
81
92
|
break;
|
|
82
93
|
}
|
|
83
94
|
} else {
|
package/nodenithy/run.js
CHANGED
|
@@ -497,6 +497,28 @@ const main = async () => {
|
|
|
497
497
|
console.error("Error: Could not fetch PUBLIC_KEY_SECURELOCK");
|
|
498
498
|
process.exit(1);
|
|
499
499
|
}
|
|
500
|
+
// ESR (RFC §5.2): if the extraction relayed the enclave's ESR wallet
|
|
501
|
+
// address (public_key_service writes ESR_WALLET_ADDRESS.txt when the
|
|
502
|
+
// service provides it), persist it to .env so a dApp can read/fund the
|
|
503
|
+
// enclave wallet. Absent file => not an ESR project, or the (mainnet)
|
|
504
|
+
// address must be learned from a local SGX extraction — either way,
|
|
505
|
+
// silently skip. Parity with the Python SDK's esr.wallet_address.
|
|
506
|
+
try {
|
|
507
|
+
if (fs.existsSync('ESR_WALLET_ADDRESS.txt')) {
|
|
508
|
+
const esrAddr = fs.readFileSync('ESR_WALLET_ADDRESS.txt', 'utf8').trim();
|
|
509
|
+
if (/^0x[0-9a-fA-F]{40}$/.test(esrAddr)) {
|
|
510
|
+
writeEnv('ESR_WALLET_ADDRESS', esrAddr);
|
|
511
|
+
console.log(`ESR_WALLET_ADDRESS: ${esrAddr}`);
|
|
512
|
+
// Manual funding by the data owner is the default path;
|
|
513
|
+
// publishing never transfers value on its own. Say so, or
|
|
514
|
+
// the first task needing gas fails with no clue why.
|
|
515
|
+
console.log(' This wallet starts EMPTY. Fund it from your own wallet with');
|
|
516
|
+
console.log(' whatever your payload needs; publishing never transfers value.');
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
} catch (e) {
|
|
520
|
+
console.warn(`Could not persist ESR_WALLET_ADDRESS: ${e && e.message ? e.message : e}`);
|
|
521
|
+
}
|
|
500
522
|
};
|
|
501
523
|
}
|
|
502
524
|
|
package/package.json
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ethernity-cloud/sdk-js",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "A package to run ethernity cloud build scripts",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"ecld-build": "cross-env node ./node_modules/@ethernity-cloud/sdk-js/build.js",
|
|
7
7
|
"ecld-publish": "cross-env node ./node_modules/@ethernity-cloud/sdk-js/publish.js",
|
|
8
8
|
"ecld-init": "cross-env node ./node_modules/@ethernity-cloud/sdk-js/init.js",
|
|
9
9
|
"ecld-run": "cross-env ecld-run",
|
|
10
|
-
"postinstall": "node postinstall.js"
|
|
10
|
+
"postinstall": "node postinstall.js",
|
|
11
|
+
"ecld-test": "cross-env node ./node_modules/@ethernity-cloud/sdk-js/test.js"
|
|
11
12
|
},
|
|
12
13
|
"bin": {
|
|
13
14
|
"ecld-build": "./build.js",
|
|
14
15
|
"ecld-publish": "./publish.js",
|
|
15
16
|
"ecld-init": "./init.js",
|
|
16
|
-
"postinstall": "./postinstall.js"
|
|
17
|
+
"postinstall": "./postinstall.js",
|
|
18
|
+
"ecld-test": "./test.js"
|
|
17
19
|
},
|
|
18
20
|
"files": [
|
|
19
21
|
"nodenithy/*",
|
|
@@ -45,4 +47,4 @@
|
|
|
45
47
|
},
|
|
46
48
|
"author": "Alexandru Luca",
|
|
47
49
|
"license": "AGPL-3.0"
|
|
48
|
-
}
|
|
50
|
+
}
|
package/test.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* ecld-test — run a Nodenithy payload locally, without SGX, exactly as the
|
|
3
|
+
* enclave would.
|
|
4
|
+
*
|
|
5
|
+
* Stages the project's src/serverless next to the vendored securelock
|
|
6
|
+
* executor (the same layout ecld-build produces inside the enclave image) and
|
|
7
|
+
* executes the payload with that executor: same backend reflection, same
|
|
8
|
+
* ___etny_result___ / ___etny_data_set___ scope, same TaskStatus codes.
|
|
9
|
+
* Validates the CALL — names, arguments, result wiring — before anything is
|
|
10
|
+
* paid for on-chain. It does not validate attestation, checksums or
|
|
11
|
+
* encryption; those need a real (testnet) run.
|
|
12
|
+
*
|
|
13
|
+
* Usage, from the project root:
|
|
14
|
+
*
|
|
15
|
+
* npx ecld-test 'hello("World")' one-shot, in-process
|
|
16
|
+
* npx ecld-test --file payload.js
|
|
17
|
+
* npx ecld-test --input data.json 'processData(___etny_data_set___)'
|
|
18
|
+
* npx ecld-test --input-text '{"x":1}' 'processData(___etny_data_set___)'
|
|
19
|
+
*
|
|
20
|
+
* npx ecld-test serve [--port 8745] local API for runner LOCAL mode
|
|
21
|
+
*
|
|
22
|
+
* `serve` starts the local test API so a dApp can exercise its real runner
|
|
23
|
+
* integration end to end: new EthernityCloudRunner('LOCAL') in
|
|
24
|
+
* @ethernity-cloud/runner >= 0.3.6 executes every run() against this API
|
|
25
|
+
* instead of the blockchain.
|
|
26
|
+
*
|
|
27
|
+
* Exit code 0 on TaskStatus SUCCESS, 1 otherwise.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const os = require('os');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const http = require('http');
|
|
34
|
+
const crypto = require('crypto');
|
|
35
|
+
|
|
36
|
+
const TASK_STATUS_NAMES = {
|
|
37
|
+
0: 'SUCCESS',
|
|
38
|
+
1: 'SYSTEM_ERROR',
|
|
39
|
+
2: 'KEY_ERROR',
|
|
40
|
+
3: 'SYNTAX_WARNING',
|
|
41
|
+
4: 'BASE_EXCEPTION',
|
|
42
|
+
5: 'PAYLOAD_NOT_DEFINED',
|
|
43
|
+
6: 'PAYLOAD_CHECKSUM_ERROR',
|
|
44
|
+
7: 'INPUT_CHECKSUM_ERROR',
|
|
45
|
+
8: 'EVAL_ERROR',
|
|
46
|
+
28: 'IMPORT_ERROR',
|
|
47
|
+
32: 'CONFIG_ERROR',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const VENDORED_SRC = path.join(__dirname, 'nodenithy', 'build', 'securelock', 'src');
|
|
51
|
+
|
|
52
|
+
function copyDir(src, dest) {
|
|
53
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
54
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
55
|
+
const from = path.join(src, entry.name);
|
|
56
|
+
const to = path.join(dest, entry.name);
|
|
57
|
+
if (entry.isDirectory()) copyDir(from, to);
|
|
58
|
+
else fs.copyFileSync(from, to);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Stage the executor + the project's backend the way ecld-build does inside
|
|
63
|
+
* the enclave image, then load the REAL executor from the staged copy. */
|
|
64
|
+
function loadExecutor(projectSrc) {
|
|
65
|
+
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'ecld-test-'));
|
|
66
|
+
for (const f of ['etny_exec.js', 'task_status.js']) {
|
|
67
|
+
fs.copyFileSync(path.join(VENDORED_SRC, f), path.join(stage, f));
|
|
68
|
+
}
|
|
69
|
+
const backendDir = path.join(projectSrc, 'serverless');
|
|
70
|
+
const backendJs = path.join(backendDir, 'backend.js');
|
|
71
|
+
if (fs.existsSync(backendJs)) {
|
|
72
|
+
copyDir(backendDir, path.join(stage, 'serverless'));
|
|
73
|
+
console.log(`backend : ${backendJs}`);
|
|
74
|
+
} else if (fs.existsSync(path.join(backendDir, 'backend.py'))) {
|
|
75
|
+
console.log('backend : backend.py found — this is a Pynithy project; use');
|
|
76
|
+
console.log(' ecld-test from ethernity-cloud-sdk-py instead.');
|
|
77
|
+
console.log(' Continuing with bare globals (stock-enclave mode).');
|
|
78
|
+
} else {
|
|
79
|
+
console.log('backend : none found — running with bare globals, like a stock enclave');
|
|
80
|
+
}
|
|
81
|
+
return require(path.join(stage, 'etny_exec.js'));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function runTask(executor, payload, input) {
|
|
85
|
+
const [code, resultRaw] = executor.executeTask(payload, input || '');
|
|
86
|
+
const result = typeof resultRaw === 'string' ? resultRaw : JSON.stringify(resultRaw);
|
|
87
|
+
const checksum = crypto.createHash('sha256').update(result, 'utf8').digest('hex');
|
|
88
|
+
const challenge = Array.from({ length: 20 }, () =>
|
|
89
|
+
'abcdefghijklmnopqrstuvwxyz0123456789'[Math.floor(Math.random() * 36)]
|
|
90
|
+
).join('');
|
|
91
|
+
return {
|
|
92
|
+
task_code: code,
|
|
93
|
+
task_code_name: TASK_STATUS_NAMES[code] || `UNKNOWN_${code}`,
|
|
94
|
+
result,
|
|
95
|
+
checksum,
|
|
96
|
+
enclave_challenge: challenge,
|
|
97
|
+
result_string: `v3:${code}:${checksum}:${challenge}:`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function serve(executor, host, port) {
|
|
102
|
+
const server = http.createServer((req, res) => {
|
|
103
|
+
const send = (status, obj) => {
|
|
104
|
+
const body = JSON.stringify(obj);
|
|
105
|
+
res.writeHead(status, {
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
'Content-Length': Buffer.byteLength(body),
|
|
108
|
+
'Access-Control-Allow-Origin': '*',
|
|
109
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
110
|
+
});
|
|
111
|
+
res.end(body);
|
|
112
|
+
};
|
|
113
|
+
if (req.method === 'OPTIONS') return send(204, {});
|
|
114
|
+
if (req.method === 'GET' && req.url === '/v1/health') {
|
|
115
|
+
// Probe with an expression that lists scope? Keep it simple: report ok.
|
|
116
|
+
return send(200, { status: 'ok', backend: serve.backendFunctions || [] });
|
|
117
|
+
}
|
|
118
|
+
if (req.method === 'POST' && req.url === '/v1/task') {
|
|
119
|
+
let raw = '';
|
|
120
|
+
req.on('data', (c) => { raw += c; });
|
|
121
|
+
req.on('end', () => {
|
|
122
|
+
try {
|
|
123
|
+
const body = JSON.parse(raw || '{}');
|
|
124
|
+
if (typeof body.payload !== 'string' || !body.payload.trim()) {
|
|
125
|
+
return send(400, { error: 'payload (string) is required' });
|
|
126
|
+
}
|
|
127
|
+
let input = body.input;
|
|
128
|
+
if (input !== undefined && input !== null && typeof input !== 'string') {
|
|
129
|
+
input = JSON.stringify(input);
|
|
130
|
+
}
|
|
131
|
+
const out = runTask(executor, body.payload, input);
|
|
132
|
+
console.log(`[local-api] task -> ${out.task_code} (${out.task_code_name})`);
|
|
133
|
+
return send(200, out);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
if (e instanceof SyntaxError) return send(400, { error: 'invalid JSON body' });
|
|
136
|
+
return send(500, { error: e.message });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
send(404, { error: 'not found' });
|
|
142
|
+
});
|
|
143
|
+
server.listen(port, host, () => {
|
|
144
|
+
console.log(`[local-api] listening on http://${host}:${port} (POST /v1/task, GET /v1/health)`);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function main() {
|
|
149
|
+
const argv = process.argv.slice(2);
|
|
150
|
+
const opts = { src: 'src', host: '127.0.0.1', port: 8745 };
|
|
151
|
+
const positional = [];
|
|
152
|
+
for (let i = 0; i < argv.length; i++) {
|
|
153
|
+
const a = argv[i];
|
|
154
|
+
if (a === '--file' || a === '-f') opts.file = argv[++i];
|
|
155
|
+
else if (a === '--input' || a === '-i') opts.inputFile = argv[++i];
|
|
156
|
+
else if (a === '--input-text') opts.inputText = argv[++i];
|
|
157
|
+
else if (a === '--src') opts.src = argv[++i];
|
|
158
|
+
else if (a === '--host') opts.host = argv[++i];
|
|
159
|
+
else if (a === '--port') opts.port = parseInt(argv[++i], 10);
|
|
160
|
+
else positional.push(a);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const projectSrc = path.resolve(opts.src);
|
|
164
|
+
const executor = loadExecutor(projectSrc);
|
|
165
|
+
// Record the backend's exported names once for /v1/health.
|
|
166
|
+
try {
|
|
167
|
+
serve.backendFunctions = Object.keys(require(path.join(projectSrc, 'serverless', 'backend.js')));
|
|
168
|
+
} catch (e) {
|
|
169
|
+
serve.backendFunctions = [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (positional[0] === 'serve') {
|
|
173
|
+
return serve(executor, opts.host, opts.port);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let payload;
|
|
177
|
+
if (opts.file) payload = fs.readFileSync(opts.file, 'utf8');
|
|
178
|
+
else if (positional.length) payload = positional.join(' ');
|
|
179
|
+
else {
|
|
180
|
+
console.error("usage: ecld-test 'hello(\"World\")' | ecld-test --file payload.js | ecld-test serve");
|
|
181
|
+
process.exit(2);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let input = null;
|
|
185
|
+
if (opts.inputFile && opts.inputText !== undefined) {
|
|
186
|
+
console.error('--input and --input-text are mutually exclusive');
|
|
187
|
+
process.exit(2);
|
|
188
|
+
}
|
|
189
|
+
if (opts.inputFile) input = fs.readFileSync(opts.inputFile, 'utf8');
|
|
190
|
+
else if (opts.inputText !== undefined) input = opts.inputText;
|
|
191
|
+
|
|
192
|
+
console.log(`payload : ${JSON.stringify(payload)}`);
|
|
193
|
+
console.log(`input : ${input === null ? '<empty>' : JSON.stringify(input.slice(0, 80))}`);
|
|
194
|
+
const out = runTask(executor, payload, input);
|
|
195
|
+
console.log(`task code: ${out.task_code} (${out.task_code_name})`);
|
|
196
|
+
console.log(`result : ${JSON.stringify(out.result)}`);
|
|
197
|
+
if (out.task_code !== 0) {
|
|
198
|
+
console.log('\nThis is exactly what the network would write on-chain — the failed');
|
|
199
|
+
console.log('attempt would still cost gas. Fix locally, then submit.');
|
|
200
|
+
}
|
|
201
|
+
process.exit(out.task_code === 0 ? 0 : 1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
main();
|