@ethernity-cloud/sdk-js 1.5.19 → 1.6.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/README.md +10 -1
- package/cas/config.js +36 -0
- package/cas/resolver.js +95 -0
- package/cas/session_registry.js +142 -0
- package/nodenithy/build/securelock/src/esr_wallet.js +6 -3
- package/nodenithy/build/securelock/src/securelock.js.tmpl +46 -14
- package/nodenithy/build.mjs +11 -7
- package/nodenithy/run/etny-securelock-test.yaml.tpl +7 -2
- package/nodenithy/run/image_registry.js +9 -0
- package/nodenithy/run.js +71 -6
- package/package.json +2 -1
- package/pynithy/build/securelock/src/securelock.py.tmpl +20 -8
- package/pynithy/build.mjs +11 -6
- package/pynithy/run/etny-securelock-test.yaml.tpl +8 -2
- package/pynithy/run/image_registry.js +9 -0
- package/pynithy/run.js +72 -6
package/README.md
CHANGED
|
@@ -39,7 +39,16 @@ The sdk has been tested on the following operating systems:
|
|
|
39
39
|
|
|
40
40
|
## Blockchain compatibility
|
|
41
41
|
- Bloxberg:
|
|
42
|
-
- Testnet - tested and working
|
|
42
|
+
- Testnet - tested and working. CAS-attested: the securelock is
|
|
43
|
+
provisioned by the ethernity-cas validator set exactly as on mainnet.
|
|
44
|
+
`ecld-publish` registers the securelock session on-chain in the
|
|
45
|
+
SessionRegistry (`0xcb1F389b...`, see `cas/config.js`), resolves a CAS
|
|
46
|
+
from the ValidatorRegistry (`0xC40102c0...`, or `ECLD_CAS_ADDR=host:port`)
|
|
47
|
+
for the compose and the certificate harvest, takes the trustedzone
|
|
48
|
+
session name from the ImageRegistry, and links the session to the
|
|
49
|
+
published image. `ecld-build` signs the securelock `--production`.
|
|
50
|
+
Harvesting the certificate needs SGX. The other testnets have no CAS
|
|
51
|
+
deployment and self-sign from MR_ENCLAVE as before.
|
|
43
52
|
- Mainnet - to be provided during the following updates
|
|
44
53
|
- Polyhon:
|
|
45
54
|
- Amoy Testnet - to be provided during the following updates
|
package/cas/config.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// ethernity-cas deployments, keyed by trustedzone template name (the value of
|
|
2
|
+
// TRUSTED_ZONE_IMAGE), for the networks whose enclaves are attested by the
|
|
3
|
+
// ethernity-cas validator set.
|
|
4
|
+
//
|
|
5
|
+
// A testnet template listed in SESSION_REGISTRY is CAS-attested: ecld-publish
|
|
6
|
+
// registers the securelock session there (body pinned to IPFS as
|
|
7
|
+
// CIDv1/raw/sha2-256) instead of posting it to a CAS, provisions the enclave
|
|
8
|
+
// from a validator resolved out of VALIDATOR_REGISTRY, and the securelock
|
|
9
|
+
// takes its certificate from the CAS session and verifies the CAS against the
|
|
10
|
+
// registry baked into it. A testnet template NOT listed here has no CAS: its
|
|
11
|
+
// securelock self-signs from MR_ENCLAVE. Mainnet templates are provisioned by
|
|
12
|
+
// the Scontain CAS and are not listed.
|
|
13
|
+
//
|
|
14
|
+
// Kept in sync with etny-{pynithy,nodenithy}/v3/networks.yaml
|
|
15
|
+
// (cas_session_registry_address, validator_registry_address).
|
|
16
|
+
const VALIDATOR_REGISTRY = {
|
|
17
|
+
'etny-pynithy-testnet': '0xC40102c0b3f87663C925083861F38e2498C2038F',
|
|
18
|
+
'etny-nodenithy-testnet': '0xC40102c0b3f87663C925083861F38e2498C2038F',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const SESSION_REGISTRY = {
|
|
22
|
+
'etny-pynithy-testnet': '0xcb1F389bF4524d1D61EDcbC24eC1F1F9C3FF4Fa6',
|
|
23
|
+
'etny-nodenithy-testnet': '0xcb1F389bF4524d1D61EDcbC24eC1F1F9C3FF4Fa6',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// The chain the registries above live on.
|
|
27
|
+
const CHAIN = {
|
|
28
|
+
'etny-pynithy-testnet': { rpcUrl: 'https://bloxberg.ethernity.cloud', chainId: 8995 },
|
|
29
|
+
'etny-nodenithy-testnet': { rpcUrl: 'https://bloxberg.ethernity.cloud', chainId: 8995 },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Whether the securelock takes its certificate from a CAS session: mainnet
|
|
33
|
+
// (Scontain CAS), or a testnet template with a SessionRegistry (ethernity-cas).
|
|
34
|
+
const casProvisioned = (templateName, isMainnet) => isMainnet || Boolean(SESSION_REGISTRY[templateName]);
|
|
35
|
+
|
|
36
|
+
module.exports = { VALIDATOR_REGISTRY, SESSION_REGISTRY, CHAIN, casProvisioned };
|
package/cas/resolver.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Which CAS a CAS-attested testnet's enclaves are provisioned from.
|
|
2
|
+
//
|
|
3
|
+
// The validators are enumerated from the ValidatorRegistry and their enclave
|
|
4
|
+
// endpoints read from the CasKeyStore the registry names via keyStore() --
|
|
5
|
+
// the same chain data the node's own resolver uses before every task. Only
|
|
6
|
+
// /dns4, /dns, /ip4 and /ip6 endpoints are dialled: the SDK runs on a
|
|
7
|
+
// developer's machine with no Tor proxy. An endpoint is taken only after its
|
|
8
|
+
// REST port answered GET /validator/identity.
|
|
9
|
+
//
|
|
10
|
+
// Multiaddrs advertise the ENCLAVE port; the REST port follows the pairing
|
|
11
|
+
// convention rest = 9081 + (enclave - 19765). Enclave ports outside
|
|
12
|
+
// [19765, 19965) fall back to REST 9081.
|
|
13
|
+
//
|
|
14
|
+
// ECLD_CAS_ADDR=host:port overrides resolution.
|
|
15
|
+
const axios = require('axios');
|
|
16
|
+
const { ethers } = require('ethers');
|
|
17
|
+
|
|
18
|
+
const REGISTRY_ABI = [
|
|
19
|
+
'function validatorCount() view returns (uint256)',
|
|
20
|
+
'function validatorSet(uint256) view returns (address)',
|
|
21
|
+
'function isValidator(address) view returns (bool)',
|
|
22
|
+
'function keyStore() view returns (address)',
|
|
23
|
+
];
|
|
24
|
+
const STORE_ABI = ['function multiaddrsOf(address) view returns (string[])'];
|
|
25
|
+
|
|
26
|
+
const ENCLAVE_PORT_BASE = 19765;
|
|
27
|
+
const REST_PORT_BASE = 9081;
|
|
28
|
+
|
|
29
|
+
// "/dns4|dns|ip4|ip6/HOST/tcp/PORT" -> {host, port}; anything else null.
|
|
30
|
+
function parseMultiaddr(ma) {
|
|
31
|
+
const parts = String(ma).split('/').filter((p) => p !== '');
|
|
32
|
+
if (parts.length !== 4 || parts[2] !== 'tcp') return null;
|
|
33
|
+
if (!['dns4', 'dns6', 'dns', 'ip4', 'ip6'].includes(parts[0])) return null;
|
|
34
|
+
const port = Number(parts[3]);
|
|
35
|
+
if (!Number.isInteger(port)) return null;
|
|
36
|
+
return { host: parts[1], port };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function restPortFor(enclavePort) {
|
|
40
|
+
if (enclavePort >= ENCLAVE_PORT_BASE && enclavePort < ENCLAVE_PORT_BASE + 200) {
|
|
41
|
+
return REST_PORT_BASE + (enclavePort - ENCLAVE_PORT_BASE);
|
|
42
|
+
}
|
|
43
|
+
return REST_PORT_BASE;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The address the CAS REST port reports for itself, or null when it did not answer.
|
|
47
|
+
async function identityAnswers(host, restPort, timeoutMs) {
|
|
48
|
+
try {
|
|
49
|
+
const r = await axios.get(`http://${host}:${restPort}/validator/identity`, { timeout: timeoutMs });
|
|
50
|
+
return (r.data && r.data.address) || null;
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// "host:port" (the ENCLAVE port) of the first active validator whose REST port
|
|
57
|
+
// answers, sweeping from a rotating offset; null when none answered.
|
|
58
|
+
async function resolveCas(rpcUrl, registryAddress, probeTimeoutMs = 10000, startAt = null) {
|
|
59
|
+
const provider = new ethers.providers.JsonRpcProvider({ url: rpcUrl, timeout: 30000 });
|
|
60
|
+
const reg = new ethers.Contract(registryAddress, REGISTRY_ABI, provider);
|
|
61
|
+
const total = (await reg.validatorCount()).toNumber();
|
|
62
|
+
if (total === 0) return null;
|
|
63
|
+
const store = new ethers.Contract(await reg.keyStore(), STORE_ABI, provider);
|
|
64
|
+
const offset = startAt === null ? Math.floor(Math.random() * total) : startAt % total;
|
|
65
|
+
for (let step = 0; step < total; step++) {
|
|
66
|
+
const v = await reg.validatorSet((offset + step) % total);
|
|
67
|
+
if (!(await reg.isValidator(v))) continue;
|
|
68
|
+
for (const ma of await store.multiaddrsOf(v)) {
|
|
69
|
+
const parsed = parseMultiaddr(ma);
|
|
70
|
+
if (!parsed) continue;
|
|
71
|
+
if (await identityAnswers(parsed.host, restPortFor(parsed.port), probeTimeoutMs)) {
|
|
72
|
+
return `${parsed.host}:${parsed.port}`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The CAS to provision against: ECLD_CAS_ADDR when set, else resolved from
|
|
80
|
+
// chain. Throws when neither yields one -- a compose without a reachable CAS
|
|
81
|
+
// cannot harvest a certificate.
|
|
82
|
+
async function casAddressFor(rpcUrl, registryAddress) {
|
|
83
|
+
const override = String(process.env.ECLD_CAS_ADDR || '').trim();
|
|
84
|
+
if (override) return override;
|
|
85
|
+
if (!registryAddress) {
|
|
86
|
+
throw new Error('no ValidatorRegistry is known for this network and ECLD_CAS_ADDR is not set');
|
|
87
|
+
}
|
|
88
|
+
const resolved = await resolveCas(rpcUrl, registryAddress);
|
|
89
|
+
if (!resolved) {
|
|
90
|
+
throw new Error(`no CAS validator registered in ${registryAddress} answered on its REST port; set ECLD_CAS_ADDR=host:port to name one`);
|
|
91
|
+
}
|
|
92
|
+
return resolved;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { parseMultiaddr, restPortFor, identityAnswers, resolveCas, casAddressFor };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Register a securelock session ON-CHAIN, in the ethernity-cas SessionRegistry.
|
|
2
|
+
//
|
|
3
|
+
// On a CAS-attested testnet the CAS is a validator set that reads its sessions
|
|
4
|
+
// from the chain and never accepts a POST: the publisher pins the session body
|
|
5
|
+
// to IPFS and submits SessionRegistry.register itself, with the wallet that
|
|
6
|
+
// owns the ImageRegistry entry. The first registration of a name fixes its
|
|
7
|
+
// creator; later versions of the same name must come from that wallet.
|
|
8
|
+
//
|
|
9
|
+
// The body is pinned as CIDv1/raw/sha2-256, so with hashAlgo 1 the on-chain
|
|
10
|
+
// sessionHash and the CID commit to the same digest (sha256 of the exact
|
|
11
|
+
// bytes) -- what lets a validator check the body it fetched against the chain
|
|
12
|
+
// without trusting the gateway. The same recipe as etny-pynithy's
|
|
13
|
+
// v3/run/register_session.py and the Python SDK's session_registry.py.
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const axios = require('axios');
|
|
16
|
+
const { ethers } = require('ethers');
|
|
17
|
+
|
|
18
|
+
const ABI = [
|
|
19
|
+
'function register(bytes32 sessionHash, string name, string bodyCid, string imageCid, uint8 hashAlgo, (string[] tolerate, string[] ignoreAdvisories, string[] mrenclaves, uint16 minIsvSvn, bool debugAllowed, bool skipQuoteVerification) rulesIn) returns (uint32)',
|
|
20
|
+
'function latest(string name) view returns (bytes32)',
|
|
21
|
+
'function linkImage(bytes32 sessionHash, string imageCid)',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const HASH_ALGO_SHA256 = 1;
|
|
25
|
+
|
|
26
|
+
function yamlList(text) {
|
|
27
|
+
let t = text.trim();
|
|
28
|
+
if (t.startsWith('[')) {
|
|
29
|
+
const end = t.indexOf(']');
|
|
30
|
+
t = end >= 0 ? t.slice(1, end) : t.slice(1);
|
|
31
|
+
}
|
|
32
|
+
return t.split(',').map((x) => x.trim().replace(/^["']|["']$/g, '')).filter((x) => x);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The session's name and the SessionRules the registry stores beside it.
|
|
36
|
+
function parseNameAndRules(text) {
|
|
37
|
+
let name = '';
|
|
38
|
+
let tolerate = [], ignoreAdvisories = [], mrenclaves = [];
|
|
39
|
+
let minIsvSvn = 0, debugAllowed = false, skipQuoteVerification = false;
|
|
40
|
+
for (const raw of text.split('\n')) {
|
|
41
|
+
const line = raw.trim();
|
|
42
|
+
if (!name && line.startsWith('name:')) {
|
|
43
|
+
name = line.slice('name:'.length).trim().replace(/^["']|["']$/g, '');
|
|
44
|
+
} else if (line.startsWith('tolerate:')) {
|
|
45
|
+
tolerate = yamlList(line.slice('tolerate:'.length));
|
|
46
|
+
debugAllowed = tolerate.some((t) => t.toLowerCase().includes('debug'));
|
|
47
|
+
} else if (line.startsWith('ignore_advisories:')) {
|
|
48
|
+
ignoreAdvisories = yamlList(line.slice('ignore_advisories:'.length));
|
|
49
|
+
} else if (line.startsWith('mrenclaves:')) {
|
|
50
|
+
for (const m of yamlList(line.slice('mrenclaves:'.length))) {
|
|
51
|
+
if (m && !mrenclaves.includes(m)) mrenclaves.push(m);
|
|
52
|
+
}
|
|
53
|
+
} else if (line.startsWith('isvsvn:') || line.startsWith('min_isvsvn:')) {
|
|
54
|
+
const n = parseInt(line.split(':', 2)[1].trim(), 10);
|
|
55
|
+
if (!Number.isNaN(n)) minIsvSvn = n;
|
|
56
|
+
} else if (line.startsWith('skip_quote_verification:')) {
|
|
57
|
+
skipQuoteVerification = line.slice('skip_quote_verification:'.length).trim().replace(/^["']|["']$/g, '') === 'true';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { name, rules: { tolerate, ignoreAdvisories, mrenclaves, minIsvSvn, debugAllowed, skipQuoteVerification } };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sessionHash(body) {
|
|
64
|
+
return '0x' + crypto.createHash('sha256').update(body).digest('hex');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Pin the exact bytes as CIDv1/raw/sha2-256 and return the CID.
|
|
68
|
+
async function pinBody(ipfsApiUrl, body) {
|
|
69
|
+
const form = new FormData();
|
|
70
|
+
form.append('file', new Blob([body]), 'session');
|
|
71
|
+
const r = await axios.post(`${ipfsApiUrl}/api/v0/add?cid-version=1&raw-leaves=true&pin=true`, form, { timeout: 60000 });
|
|
72
|
+
return r.data.Hash;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function contract(rpcUrl, registryAddress, key) {
|
|
76
|
+
const provider = new ethers.providers.JsonRpcProvider({ url: rpcUrl, timeout: 30000 });
|
|
77
|
+
const signer = key ? new ethers.Wallet(key.startsWith('0x') ? key : `0x${key}`, provider) : provider;
|
|
78
|
+
return new ethers.Contract(registryAddress, ABI, signer);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Pin `body` and register it under its own `name:`. Returns
|
|
82
|
+
// {name, hash, cid, registered}: `registered` is false when the chain already
|
|
83
|
+
// held these exact bytes as the name's latest version.
|
|
84
|
+
async function register(rpcUrl, chainId, registryAddress, key, body, ipfsApiUrl, imageCid = '') {
|
|
85
|
+
const { name, rules } = parseNameAndRules(body.toString('utf8'));
|
|
86
|
+
if (!name) throw new Error('the session body has no `name:`');
|
|
87
|
+
const hash = sessionHash(body);
|
|
88
|
+
const reg = contract(rpcUrl, registryAddress, key);
|
|
89
|
+
const cid = await pinBody(ipfsApiUrl, body);
|
|
90
|
+
if ((await reg.latest(name)).toLowerCase() === hash) {
|
|
91
|
+
return { name, hash, cid, registered: false };
|
|
92
|
+
}
|
|
93
|
+
const tx = await reg.register(hash, name, cid, imageCid, HASH_ALGO_SHA256, rules, {
|
|
94
|
+
gasLimit: 800000, gasPrice: ethers.utils.parseUnits('1', 'mwei'), chainId,
|
|
95
|
+
});
|
|
96
|
+
const rcpt = await tx.wait();
|
|
97
|
+
if (rcpt.status !== 1) {
|
|
98
|
+
throw new Error(`SessionRegistry.register reverted for ${name} (tx ${tx.hash}): this name's creator may be another wallet, or the SessionRules shape differs from the ABI`);
|
|
99
|
+
}
|
|
100
|
+
return { name, hash, cid, registered: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Point the name's latest version at the published image CID.
|
|
104
|
+
async function linkImage(rpcUrl, chainId, registryAddress, key, name, imageCid) {
|
|
105
|
+
const reg = contract(rpcUrl, registryAddress, key);
|
|
106
|
+
const hash = await reg.latest(name);
|
|
107
|
+
if (/^0x0+$/.test(hash)) throw new Error(`no registered session named ${name}`);
|
|
108
|
+
const tx = await reg.linkImage(hash, imageCid, { gasLimit: 300000, gasPrice: ethers.utils.parseUnits('1', 'mwei'), chainId });
|
|
109
|
+
const rcpt = await tx.wait();
|
|
110
|
+
if (rcpt.status !== 1) throw new Error(`SessionRegistry.linkImage reverted for ${name} (tx ${tx.hash})`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
114
|
+
|
|
115
|
+
// Block until the chain reports `name` at `hash`, then hold while the
|
|
116
|
+
// validators pick it up: the elected writer's poll (ECAS_SESSION_WATCH_SECS,
|
|
117
|
+
// 60 s) notices the registration and generates the secrets, its recordKey
|
|
118
|
+
// transaction confirms, and the other validators' next poll adopts the
|
|
119
|
+
// recorded blob. The enclave cannot retry and the validator it dials is the
|
|
120
|
+
// node's choice, so the wait covers the LAST validator to adopt.
|
|
121
|
+
async function waitVisible(rpcUrl, registryAddress, name, hash, timeoutMs = 180000) {
|
|
122
|
+
const reg = contract(rpcUrl, registryAddress, null);
|
|
123
|
+
const deadline = Date.now() + timeoutMs;
|
|
124
|
+
for (;;) {
|
|
125
|
+
let got;
|
|
126
|
+
try { got = (await reg.latest(name)).toLowerCase(); } catch (e) { got = `(read failed: ${e.message})`; }
|
|
127
|
+
if (got === hash.toLowerCase()) {
|
|
128
|
+
console.log(`\t✔ session ${name} visible on chain at ${got}`);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
if (Date.now() >= deadline) {
|
|
132
|
+
throw new Error(`session ${name} did not become visible within ${timeoutMs / 1000}s (chain reports ${got}, expected ${hash})`);
|
|
133
|
+
}
|
|
134
|
+
await sleep(5000);
|
|
135
|
+
}
|
|
136
|
+
const poll = parseInt(process.env.CAS_SESSION_POLL_SECS || '60', 10);
|
|
137
|
+
const hold = 2 * poll + 60;
|
|
138
|
+
console.log(`\t holding ${hold}s for the CAS validators to generate and mirror it`);
|
|
139
|
+
await sleep(hold * 1000);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { parseNameAndRules, sessionHash, pinBody, register, linkImage, waitVisible, HASH_ALGO_SHA256 };
|
|
@@ -109,15 +109,18 @@ function deriveWalletAddress(identityPrivDer) {
|
|
|
109
109
|
* testnet tradeoff — treat these wallets as disposable.
|
|
110
110
|
*
|
|
111
111
|
* @param {string} networkType "mainnet" | "testnet"
|
|
112
|
+
* @param {boolean} attested whether this build is provisioned by a CAS (its
|
|
113
|
+
* key comes from the CAS session): true on mainnet and on a testnet whose
|
|
114
|
+
* enclaves are attested by the ethernity-cas validator set
|
|
112
115
|
* @returns {boolean}
|
|
113
116
|
*/
|
|
114
|
-
function isSecretIdentity(networkType) {
|
|
117
|
+
function isSecretIdentity(networkType, attested = false) {
|
|
115
118
|
const override = String(process.env.ESR_IDENTITY_SECRET || '').trim().toLowerCase();
|
|
116
119
|
if (override === '1' || override === 'true' || override === 'yes') return true;
|
|
117
120
|
if (override === '0' || override === 'false' || override === 'no') return false;
|
|
118
121
|
// Secrecy tracks attestation, not the network name: mainnet is attested,
|
|
119
|
-
//
|
|
120
|
-
return String(networkType || '').trim().toLowerCase() === 'mainnet';
|
|
122
|
+
// and so is a testnet whose securelock is provisioned by a CAS.
|
|
123
|
+
return attested || String(networkType || '').trim().toLowerCase() === 'mainnet';
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
const INSECURE_IDENTITY_WARNING =
|
|
@@ -111,16 +111,18 @@ class EtnySecureLock {
|
|
|
111
111
|
* preferred (matching the node-side resolver). Returns REST base URLs.
|
|
112
112
|
*/
|
|
113
113
|
async casEndpointsFromChain(casAddress) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
];
|
|
118
|
-
const
|
|
114
|
+
// Endpoints live in the CasKeyStore the registry names via keyStore();
|
|
115
|
+
// the registry's own forwarders were removed when it reached the
|
|
116
|
+
// EIP-170 size limit.
|
|
117
|
+
const registryAbi = ['function keyStore() view returns (address)'];
|
|
118
|
+
const storeAbi = ['function multiaddrsOf(address) view returns (string[])'];
|
|
119
119
|
let mas;
|
|
120
120
|
try {
|
|
121
|
-
|
|
121
|
+
const vr = new ethers.Contract(this.validator_registry_address, registryAbi, this.w3);
|
|
122
|
+
const store = new ethers.Contract(await vr.keyStore(), storeAbi, this.w3);
|
|
123
|
+
mas = await store.multiaddrsOf(casAddress);
|
|
122
124
|
} catch (e) {
|
|
123
|
-
console.warn(`CAS attestation:
|
|
125
|
+
console.warn(`CAS attestation: reading the CAS endpoints from chain failed: ${e.message || e}`);
|
|
124
126
|
return [];
|
|
125
127
|
}
|
|
126
128
|
const onion = [], rest = [];
|
|
@@ -323,7 +325,9 @@ class EtnySecureLock {
|
|
|
323
325
|
|
|
324
326
|
const vrAbi = [
|
|
325
327
|
'function isValidator(address) view returns (bool)',
|
|
326
|
-
|
|
328
|
+
// The registry's Validator struct: the measurement is `bytes` at
|
|
329
|
+
// its platform width (32 for SGX MRENCLAVE), not bytes32.
|
|
330
|
+
'function validators(address) view returns (bytes measurement, bytes32 certHash, bool active, uint64 admittedBlock, uint64 lastVoteBlock, uint16 teeType, address operator)',
|
|
327
331
|
];
|
|
328
332
|
let active = false, record = null, lastErr = null;
|
|
329
333
|
let casAddress = null, boundCert = null;
|
|
@@ -362,7 +366,7 @@ class EtnySecureLock {
|
|
|
362
366
|
if (!active) {
|
|
363
367
|
return fault(`CAS ${casAddress} is NOT an active validator`);
|
|
364
368
|
}
|
|
365
|
-
const registeredMr = record.
|
|
369
|
+
const registeredMr = record.measurement.replace(/^0x/, '');
|
|
366
370
|
if (registeredMr !== quoteMrenclave) {
|
|
367
371
|
return fault(`CAS quote MRENCLAVE ${quoteMrenclave} != registered ${registeredMr}`);
|
|
368
372
|
}
|
|
@@ -515,7 +519,23 @@ class EtnySecureLock {
|
|
|
515
519
|
// input carries no checksum (has_checksum === false) and is skipped. Kept
|
|
516
520
|
// identical across securelock / trustedzone / validator so all three agree.
|
|
517
521
|
async validateClientPayload(payload_data, input_data) {
|
|
518
|
-
|
|
522
|
+
// The order metadata comes from the chain; a transient RPC failure
|
|
523
|
+
// must end in a signed failure result the trustedzone can hand over,
|
|
524
|
+
// not in a task that produces nothing until the trustedzone times out.
|
|
525
|
+
const maxRetries = 5;
|
|
526
|
+
for (let attempt = 1; ; attempt++) {
|
|
527
|
+
try {
|
|
528
|
+
await this.getDORequestMetadata();
|
|
529
|
+
break;
|
|
530
|
+
} catch (e) {
|
|
531
|
+
if (attempt === maxRetries) {
|
|
532
|
+
this.task_code = TaskStatus.SYSTEM_ERROR;
|
|
533
|
+
this.task_result = `Failed to fetch metadata after ${maxRetries} attempts: ${e && e.message ? e.message : e}`;
|
|
534
|
+
this.is_valid_client_data = false;
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
519
539
|
|
|
520
540
|
const pmeta = this._metadata.payload_metadata_obj;
|
|
521
541
|
if (pmeta.has_checksum) {
|
|
@@ -677,7 +697,11 @@ class EtnySecureLock {
|
|
|
677
697
|
// Idempotent: the key is a pure function of the enclave identity, so it is
|
|
678
698
|
// identical across restarts without needing to persist it.
|
|
679
699
|
generateSelfSignedCertIfTestnet() {
|
|
680
|
-
|
|
700
|
+
// A testnet build with a ValidatorRegistry baked in is CAS-provisioned
|
|
701
|
+
// (ethernity-cas): its key and cert are injected into key_file and
|
|
702
|
+
// cert_file by the CAS session, exactly as on mainnet, and the same
|
|
703
|
+
// registry is what verifyCasAttestation checks the CAS against.
|
|
704
|
+
if (NETWORK_TYPE !== "testnet" || this.casProvisioned()) return;
|
|
681
705
|
console.log("[SecureLock] TESTNET: self-signing certificate from MR_ENCLAVE (no CAS) [TESTNET-INSECURE]");
|
|
682
706
|
const {privateKeyPem, certPem, mrEnclaveHex} = generateCertFromMrEnclave("SecureLock Enclave");
|
|
683
707
|
console.log(`[SecureLock] MR_ENCLAVE: ${mrEnclaveHex}`);
|
|
@@ -689,9 +713,17 @@ class EtnySecureLock {
|
|
|
689
713
|
fs.writeFileSync(this.cert_file, certPem);
|
|
690
714
|
}
|
|
691
715
|
|
|
716
|
+
// Whether this build's certificate comes from a CAS session: mainnet, or a
|
|
717
|
+
// testnet with a ValidatorRegistry baked in.
|
|
718
|
+
casProvisioned() {
|
|
719
|
+
const registry = String(this.validator_registry_address || '');
|
|
720
|
+
return NETWORK_TYPE === "mainnet" || (registry !== '' && !registry.startsWith('__'));
|
|
721
|
+
}
|
|
722
|
+
|
|
692
723
|
// The enclave's identity private key, in whatever form this network provides
|
|
693
|
-
// it: the in-memory PEM on testnet, or the CAS-injected
|
|
694
|
-
//
|
|
724
|
+
// it: the in-memory PEM on a self-signing testnet, or the CAS-injected
|
|
725
|
+
// key_file path otherwise. etny_crypto accepts either, so callers do not
|
|
726
|
+
// branch on network.
|
|
695
727
|
privateKeyMaterial() {
|
|
696
728
|
return this.identity_private_key_pem || this.key_file;
|
|
697
729
|
}
|
|
@@ -734,7 +766,7 @@ class EtnySecureLock {
|
|
|
734
766
|
|| fs.readFileSync(this.key_file, 'utf8');
|
|
735
767
|
const esrAddress = esr_wallet.deriveWalletAddress(identityPriv);
|
|
736
768
|
console.log(`ESR_WALLET_ADDRESS: ${esrAddress}`);
|
|
737
|
-
if (!esr_wallet.isSecretIdentity(NETWORK_TYPE)) {
|
|
769
|
+
if (!esr_wallet.isSecretIdentity(NETWORK_TYPE, this.casProvisioned())) {
|
|
738
770
|
console.warn(esr_wallet.INSECURE_IDENTITY_WARNING);
|
|
739
771
|
}
|
|
740
772
|
} catch (e) {
|
package/nodenithy/build.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import shell from 'shelljs';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { createRequire } from 'module';
|
|
4
5
|
import dotenv from 'dotenv';
|
|
5
6
|
dotenv.config();
|
|
6
7
|
|
|
@@ -56,10 +57,11 @@ export const ECRunner = {
|
|
|
56
57
|
// can verify the CAS that provisions it (ECAS_CAS_QUOTE self-attestation).
|
|
57
58
|
// Build-side on purpose: a rogue CAS must not choose the registry that judges
|
|
58
59
|
// it. Missing key = no registry on that network; the enclave skips the check.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
// Shared with publish (cas/config.js), which registers the session in the
|
|
61
|
+
// matching SessionRegistry.
|
|
62
|
+
const require = createRequire(import.meta.url);
|
|
63
|
+
const casConfig = require('../cas/config.js');
|
|
64
|
+
export const VALIDATOR_REGISTRY = casConfig.VALIDATOR_REGISTRY;
|
|
63
65
|
|
|
64
66
|
const runCommand = (command, canPass = false) => {
|
|
65
67
|
if (shell.exec(command).code !== 0 && !canPass) {
|
|
@@ -247,12 +249,14 @@ const signedMrenclaveStep =
|
|
|
247
249
|
|
|
248
250
|
let imagesTag = process.env.BLOCKCHAIN_NETWORK.toLowerCase();
|
|
249
251
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
+
// A CAS-provisioned securelock (mainnet, or a testnet with a SessionRegistry)
|
|
253
|
+
// is signed --production: the CAS session admits production enclaves only,
|
|
254
|
+
// and a debug-signed one is refused at attestation.
|
|
255
|
+
if (casConfig.casProvisioned(templateName, isMainnet)) {
|
|
252
256
|
dockerfileSecureContent = dockerfileSecureContent
|
|
253
257
|
.replace('__SCONE_SIGN__', `RUN scone-signer sign ${signFlags} --production /usr/local/bin/node`)
|
|
254
258
|
.replace('__SIGNED_MRENCLAVE__', signedMrenclaveStep);
|
|
255
|
-
imagesTag = process.env.BLOCKCHAIN_NETWORK.split("_")[0].toLowerCase();
|
|
259
|
+
if (isMainnet) imagesTag = process.env.BLOCKCHAIN_NETWORK.split("_")[0].toLowerCase();
|
|
256
260
|
} else {
|
|
257
261
|
// Testnet: non-CAS self-sign path -- no --production sign and no signed
|
|
258
262
|
// MRENCLAVE baking (the enclave self-signs from MR_ENCLAVE at runtime).
|
|
@@ -4,8 +4,13 @@ __PREDECESSOR__
|
|
|
4
4
|
|
|
5
5
|
security:
|
|
6
6
|
attestation:
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
# Both substituted per network by run.js. Mainnet attests production
|
|
8
|
+
# enclaves only and lists the advisories it accepts; a testnet tolerates a
|
|
9
|
+
# debug-signed enclave and takes the wildcard ["*"], so an operator's
|
|
10
|
+
# platform is not refused for a TCB level that has no bearing on a test
|
|
11
|
+
# network.
|
|
12
|
+
tolerate: __TOLERATE__
|
|
13
|
+
ignore_advisories: __IGNORE_ADVISORIES__
|
|
9
14
|
|
|
10
15
|
services:
|
|
11
16
|
- name: application
|
|
@@ -301,6 +301,15 @@ class ImageRegistry {
|
|
|
301
301
|
console.log(public);
|
|
302
302
|
return public;
|
|
303
303
|
}
|
|
304
|
+
// The CAS session name recorded for the trustedzone template's latest
|
|
305
|
+
// image: what the compose's SCONE_CONFIG_ID for the trustedzone must name.
|
|
306
|
+
if (action === 'getTrustedZoneSession') {
|
|
307
|
+
const imageRegistry = new ImageRegistry();
|
|
308
|
+
const latest = await imageRegistry.imageRegistryContract.getLatestTrustedZoneImageCertPublicKey(projectName, version);
|
|
309
|
+
const session = await imageRegistry.imageRegistryContract.getTrustedZoneImageSession(latest[0]);
|
|
310
|
+
console.log(session);
|
|
311
|
+
return session;
|
|
312
|
+
}
|
|
304
313
|
|
|
305
314
|
const imageRegistry = new ImageRegistry();
|
|
306
315
|
if (action === 'registerSecureLockImage') {
|
package/nodenithy/run.js
CHANGED
|
@@ -5,6 +5,9 @@ const readline = require('readline');
|
|
|
5
5
|
const forge = require('node-forge');
|
|
6
6
|
const axios = require('axios');
|
|
7
7
|
const https = require('https');
|
|
8
|
+
const casConfig = require('../cas/config.js');
|
|
9
|
+
const casResolver = require('../cas/resolver.js');
|
|
10
|
+
const sessionRegistry = require('../cas/session_registry.js');
|
|
8
11
|
|
|
9
12
|
if (!fs.existsSync('.env')) {
|
|
10
13
|
console.error("Error: .env file not found");
|
|
@@ -58,6 +61,12 @@ const writeEnv = (key, value) => {
|
|
|
58
61
|
let templateName = process.env.TRUSTED_ZONE_IMAGE || 'etny-nodenithy-testnet';
|
|
59
62
|
|
|
60
63
|
const isMainnet = !templateName.includes('testnet');
|
|
64
|
+
// A testnet template with a SessionRegistry is CAS-attested by the
|
|
65
|
+
// ethernity-cas validator set: the session is registered on-chain, the
|
|
66
|
+
// enclave is provisioned from a validator, and the certificate comes out of
|
|
67
|
+
// the CAS session. Other testnets self-sign and register nothing.
|
|
68
|
+
const casTestnet = !isMainnet && casConfig.casProvisioned(templateName, isMainnet);
|
|
69
|
+
const casChain = casConfig.CHAIN[templateName] || {};
|
|
61
70
|
|
|
62
71
|
const currentDir = process.cwd();
|
|
63
72
|
console.log(`currentDir: ${currentDir}`);
|
|
@@ -125,7 +134,7 @@ const main = async () => {
|
|
|
125
134
|
// from the signed binary -- and a runtime recompute yields a DEBUG enclave
|
|
126
135
|
// that CAS rejects ("Debug mode is enabled"). Refuse to publish rather than
|
|
127
136
|
// register an untrusted identity on-chain or fail opaquely later.
|
|
128
|
-
if (isMainnet) {
|
|
137
|
+
if (isMainnet || casTestnet) {
|
|
129
138
|
const signedMrenclave = extractSignedMrenclave('etny-securelock');
|
|
130
139
|
if (!signedMrenclave || signedMrenclave !== process.env.MRENCLAVE_SECURELOCK) {
|
|
131
140
|
console.error(`Error: securelock runtime MRENCLAVE (${process.env.MRENCLAVE_SECURELOCK}) != signed MRENCLAVE (${signedMrenclave}).`);
|
|
@@ -155,9 +164,15 @@ const main = async () => {
|
|
|
155
164
|
const regex = new RegExp(`__${key}__`, 'g');
|
|
156
165
|
content = content.replace(regex, value);
|
|
157
166
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
167
|
+
// Mainnet attests production enclaves only; a testnet tolerates a
|
|
168
|
+
// debug-signed one. Both lines were edited by text replacement, which
|
|
169
|
+
// had to repeat the advisory list verbatim to delete it and broke the
|
|
170
|
+
// moment either string moved. Written out per network instead.
|
|
171
|
+
content = content
|
|
172
|
+
.replace(/__TOLERATE__/g, isMainnet
|
|
173
|
+
? '[hyperthreading, outdated-tcb, software-hardening-needed]'
|
|
174
|
+
: '[hyperthreading, outdated-tcb, software-hardening-needed, debug-mode]')
|
|
175
|
+
.replace(/__IGNORE_ADVISORIES__/g, isMainnet ? '["INTEL-SA-00615"]' : '["*"]');
|
|
161
176
|
|
|
162
177
|
fs.writeFileSync(outputFile, content);
|
|
163
178
|
// console.log(`Contents of ${outputFile}:`);
|
|
@@ -208,7 +223,25 @@ const main = async () => {
|
|
|
208
223
|
};
|
|
209
224
|
processYamlTemplate('etny-securelock-test.yaml.tpl', 'etny-securelock-test.yaml', replacementsSecurelock);
|
|
210
225
|
|
|
211
|
-
|
|
226
|
+
// Where the session goes depends on who provisions the securelock. On a
|
|
227
|
+
// CAS-attested testnet it is registered ON-CHAIN in the ethernity-cas
|
|
228
|
+
// SessionRegistry, which the validator set reads and which accepts no
|
|
229
|
+
// POST; the publish then waits until the validators serve it, because the
|
|
230
|
+
// public-key harvest below provisions the enclave from them. Every other
|
|
231
|
+
// network keeps the Scontain CAS registration that follows.
|
|
232
|
+
if (casTestnet) {
|
|
233
|
+
const registered = await sessionRegistry.register(
|
|
234
|
+
casChain.rpcUrl, casChain.chainId, casConfig.SESSION_REGISTRY[templateName],
|
|
235
|
+
process.env.PRIVATE_KEY, fs.readFileSync('etny-securelock-test.yaml'),
|
|
236
|
+
process.env.IPFS_ENDPOINT, '');
|
|
237
|
+
if (registered.registered) {
|
|
238
|
+
console.log(`\t✔ Session ${registered.name} registered on-chain (body ${registered.cid})`);
|
|
239
|
+
await sessionRegistry.waitVisible(
|
|
240
|
+
casChain.rpcUrl, casConfig.SESSION_REGISTRY[templateName], registered.name, registered.hash);
|
|
241
|
+
} else {
|
|
242
|
+
console.log(`\t✔ Session ${registered.name} already registered on-chain with this body (${registered.hash})`);
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
212
245
|
// don't generate new keys if PREDECESSOR_HASH_SECURELOCK is not empty and the key.pem and cert.pem files exist
|
|
213
246
|
if (PREDECESSOR_HASH_SECURELOCK !== 'EMPTY' && fs.existsSync('key.pem') && fs.existsSync('cert.pem')) {
|
|
214
247
|
console.log("Skipping keypair generation and certificate creation.");
|
|
@@ -322,6 +355,7 @@ const main = async () => {
|
|
|
322
355
|
console.log("Please change the name/version of your project (using ecld-init or by editing .env file) and run the scripts again. Exiting.");
|
|
323
356
|
process.exit(1);
|
|
324
357
|
});
|
|
358
|
+
}
|
|
325
359
|
|
|
326
360
|
|
|
327
361
|
// const ENCLAVE_NAME_TRUSTEDZONE = generateEnclaveName(process.env.ENCLAVE_NAME_TRUSTEDZONE);
|
|
@@ -356,6 +390,23 @@ const main = async () => {
|
|
|
356
390
|
|
|
357
391
|
console.log("# Update docker-compose files");
|
|
358
392
|
|
|
393
|
+
// On a CAS-attested testnet the compose names the CAS both enclaves are
|
|
394
|
+
// provisioned from (a validator resolved from the registry, or
|
|
395
|
+
// ECLD_CAS_ADDR; the node re-resolves it before each task) and the
|
|
396
|
+
// trustedzone session the ImageRegistry records for this template.
|
|
397
|
+
let casAddr = null;
|
|
398
|
+
let trustedZoneSession = null;
|
|
399
|
+
if (casTestnet) {
|
|
400
|
+
casAddr = await casResolver.casAddressFor(casChain.rpcUrl, casConfig.VALIDATOR_REGISTRY[templateName]);
|
|
401
|
+
console.log(`\t✔ CAS for this network: ${casAddr}`);
|
|
402
|
+
trustedZoneSession = execSync(`node ./image_registry.js "${process.env.BLOCKCHAIN_NETWORK}" ${templateName} "v3" "" "getTrustedZoneSession"`).toString().trim();
|
|
403
|
+
if (!trustedZoneSession) {
|
|
404
|
+
console.error(`ERROR! The ImageRegistry records no trustedzone session for ${templateName}.`);
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
console.log(`\t✔ Trustedzone session: ${trustedZoneSession}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
359
410
|
const files = ['docker-compose.yml', 'docker-compose-final.yml'];
|
|
360
411
|
|
|
361
412
|
files.forEach(file => {
|
|
@@ -385,9 +436,15 @@ const main = async () => {
|
|
|
385
436
|
if (isMainnet) {
|
|
386
437
|
ENCLAVE_NAME_TRUSTEDZONE = 'ecld-nodenithy-trustedzone-v3-3.0.0'
|
|
387
438
|
}
|
|
388
|
-
|
|
439
|
+
if (casTestnet) {
|
|
440
|
+
ENCLAVE_NAME_TRUSTEDZONE = trustedZoneSession;
|
|
441
|
+
}
|
|
442
|
+
let updatedContent = fileContentBefore
|
|
389
443
|
.replace(/__ENCLAVE_NAME_SECURELOCK__/g, ENCLAVE_NAME_SECURELOCK)
|
|
390
444
|
.replace(/__ENCLAVE_NAME_TRUSTEDZONE__/g, ENCLAVE_NAME_TRUSTEDZONE);
|
|
445
|
+
if (casTestnet) {
|
|
446
|
+
updatedContent = updatedContent.replace(/SCONE_CAS_ADDR=scone-cas\.cf/g, `SCONE_CAS_ADDR=${casAddr}`);
|
|
447
|
+
}
|
|
391
448
|
|
|
392
449
|
fs.writeFileSync(file, updatedContent, 'utf8');
|
|
393
450
|
|
|
@@ -596,6 +653,14 @@ const main = async () => {
|
|
|
596
653
|
if (!existing) {
|
|
597
654
|
const res = execSync(`node ${runDir}/image_registry.js "${process.env.BLOCKCHAIN_NETWORK}" "" "" "" "registerSecureLockImage"`, { stdio: "inherit" });
|
|
598
655
|
}
|
|
656
|
+
// The on-chain session points at the image it admits, so a validator can
|
|
657
|
+
// pin the image beside the body it serves.
|
|
658
|
+
if (casTestnet) {
|
|
659
|
+
await sessionRegistry.linkImage(
|
|
660
|
+
casChain.rpcUrl, casChain.chainId, casConfig.SESSION_REGISTRY[templateName],
|
|
661
|
+
process.env.PRIVATE_KEY, ENCLAVE_NAME_SECURELOCK, process.env.IPFS_HASH);
|
|
662
|
+
console.log(`\t✔ Session ${ENCLAVE_NAME_SECURELOCK} linked to image ${process.env.IPFS_HASH}`);
|
|
663
|
+
}
|
|
599
664
|
console.log("Script completed successfully. You can start testing the application now. (eg. npm run start)");
|
|
600
665
|
process.exit(0);
|
|
601
666
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ethernity-cloud/sdk-js",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"files": [
|
|
24
24
|
"nodenithy/*",
|
|
25
25
|
"pynithy/*",
|
|
26
|
+
"cas/*",
|
|
26
27
|
"build.js",
|
|
27
28
|
"init.js",
|
|
28
29
|
"publish.js",
|
|
@@ -214,17 +214,25 @@ class EtnySecureLock:
|
|
|
214
214
|
itself. /onion3 is preferred, then the rest, matching the node-side
|
|
215
215
|
resolver. Returns REST base URLs.
|
|
216
216
|
"""
|
|
217
|
-
|
|
217
|
+
# Endpoints live in the CasKeyStore the registry names via keyStore();
|
|
218
|
+
# the registry's own forwarders were removed when it reached the
|
|
219
|
+
# EIP-170 size limit.
|
|
220
|
+
registry_abi = [
|
|
221
|
+
{"name": "keyStore", "type": "function", "stateMutability": "view",
|
|
222
|
+
"inputs": [], "outputs": [{"type": "address"}]},
|
|
223
|
+
]
|
|
224
|
+
store_abi = [
|
|
218
225
|
{"name": "multiaddrsOf", "type": "function", "stateMutability": "view",
|
|
219
226
|
"inputs": [{"type": "address"}], "outputs": [{"type": "string[]"}]},
|
|
220
227
|
]
|
|
221
|
-
vr = self.w3.eth.contract(
|
|
222
|
-
address=self.w3.toChecksumAddress(self.validator_registry_address),
|
|
223
|
-
abi=vr_abi)
|
|
224
228
|
try:
|
|
225
|
-
|
|
229
|
+
vr = self.w3.eth.contract(
|
|
230
|
+
address=self.w3.toChecksumAddress(self.validator_registry_address),
|
|
231
|
+
abi=registry_abi)
|
|
232
|
+
store = self.w3.eth.contract(address=vr.caller().keyStore(), abi=store_abi)
|
|
233
|
+
mas = store.caller().multiaddrsOf(self.w3.toChecksumAddress(cas_address))
|
|
226
234
|
except Exception as e:
|
|
227
|
-
print(f'CAS attestation:
|
|
235
|
+
print(f'CAS attestation: reading the CAS endpoints from chain failed: {e}')
|
|
228
236
|
return []
|
|
229
237
|
onion, rest = [], []
|
|
230
238
|
for ma in mas:
|
|
@@ -378,13 +386,17 @@ class EtnySecureLock:
|
|
|
378
386
|
vr_abi = [
|
|
379
387
|
{"name": "isValidator", "type": "function", "stateMutability": "view",
|
|
380
388
|
"inputs": [{"type": "address"}], "outputs": [{"type": "bool"}]},
|
|
389
|
+
# The registry's Validator struct: the measurement is `bytes` at
|
|
390
|
+
# its platform width (32 for SGX MRENCLAVE), not bytes32.
|
|
381
391
|
{"name": "validators", "type": "function", "stateMutability": "view",
|
|
382
392
|
"inputs": [{"type": "address"}],
|
|
383
|
-
"outputs": [{"name": "
|
|
393
|
+
"outputs": [{"name": "measurement", "type": "bytes"},
|
|
384
394
|
{"name": "certHash", "type": "bytes32"},
|
|
385
395
|
{"name": "active", "type": "bool"},
|
|
386
396
|
{"name": "admittedBlock", "type": "uint64"},
|
|
387
|
-
{"name": "lastVoteBlock", "type": "uint64"}
|
|
397
|
+
{"name": "lastVoteBlock", "type": "uint64"},
|
|
398
|
+
{"name": "teeType", "type": "uint16"},
|
|
399
|
+
{"name": "operator", "type": "address"}]},
|
|
388
400
|
]
|
|
389
401
|
last_err = None
|
|
390
402
|
cas_address, bound_cert, active, record = None, None, False, None
|
package/pynithy/build.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import shell from 'shelljs';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { createRequire } from 'module';
|
|
4
5
|
import dotenv from 'dotenv';
|
|
5
6
|
dotenv.config();
|
|
6
7
|
|
|
@@ -56,10 +57,11 @@ export const ECRunner = {
|
|
|
56
57
|
// can verify the CAS that provisions it (ECAS_CAS_QUOTE self-attestation).
|
|
57
58
|
// Build-side on purpose: a rogue CAS must not choose the registry that judges
|
|
58
59
|
// it. Missing key = no registry on that network; the enclave skips the check.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
// Shared with publish (cas/config.js), which registers the session in the
|
|
61
|
+
// matching SessionRegistry.
|
|
62
|
+
const require = createRequire(import.meta.url);
|
|
63
|
+
const casConfig = require('../cas/config.js');
|
|
64
|
+
export const VALIDATOR_REGISTRY = casConfig.VALIDATOR_REGISTRY;
|
|
63
65
|
|
|
64
66
|
const runCommand = (command, canPass = false) => {
|
|
65
67
|
if (shell.exec(command).code !== 0 && !canPass) {
|
|
@@ -155,11 +157,14 @@ const signedMrenclaveStep =
|
|
|
155
157
|
|
|
156
158
|
let imagesTag = process.env.BLOCKCHAIN_NETWORK.toLowerCase();
|
|
157
159
|
|
|
158
|
-
|
|
160
|
+
// A CAS-provisioned securelock (mainnet, or a testnet with a SessionRegistry)
|
|
161
|
+
// is signed --production: the CAS session admits production enclaves only,
|
|
162
|
+
// and a debug-signed one is refused at attestation.
|
|
163
|
+
if (casConfig.casProvisioned(templateName, isMainnet)) {
|
|
159
164
|
dockerfileSecureContent = dockerfileSecureContent
|
|
160
165
|
.replace('__SCONE_SIGN__', `RUN scone-signer sign ${signFlags} --production /usr/local/bin/python`)
|
|
161
166
|
.replace('__SIGNED_MRENCLAVE__', signedMrenclaveStep);
|
|
162
|
-
imagesTag = process.env.BLOCKCHAIN_NETWORK.split("_")[0].toLowerCase();
|
|
167
|
+
if (isMainnet) imagesTag = process.env.BLOCKCHAIN_NETWORK.split("_")[0].toLowerCase();
|
|
163
168
|
} else {
|
|
164
169
|
dockerfileSecureContent = dockerfileSecureContent
|
|
165
170
|
.replace('__SCONE_SIGN__', '# testnet: non-CAS self-sign (no --production sign at build time)')
|
|
@@ -4,8 +4,14 @@ __PREDECESSOR__
|
|
|
4
4
|
|
|
5
5
|
security:
|
|
6
6
|
attestation:
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
# Both substituted per network by run.js. Mainnet attests production
|
|
8
|
+
# enclaves only and lists the advisories it accepts; a testnet tolerates a
|
|
9
|
+
# debug-signed enclave and takes the wildcard ["*"], so an operator's
|
|
10
|
+
# platform is not refused for a TCB level that has no bearing on a test
|
|
11
|
+
# network. (A bare "*" is not a list: the CAS reads it as a literal
|
|
12
|
+
# advisory id, which ignores nothing.)
|
|
13
|
+
tolerate: __TOLERATE__
|
|
14
|
+
ignore_advisories: __IGNORE_ADVISORIES__
|
|
9
15
|
|
|
10
16
|
services:
|
|
11
17
|
- name: application
|
|
@@ -301,6 +301,15 @@ class ImageRegistry {
|
|
|
301
301
|
console.log(public);
|
|
302
302
|
return public;
|
|
303
303
|
}
|
|
304
|
+
// The CAS session name recorded for the trustedzone template's latest
|
|
305
|
+
// image: what the compose's SCONE_CONFIG_ID for the trustedzone must name.
|
|
306
|
+
if (action === 'getTrustedZoneSession') {
|
|
307
|
+
const imageRegistry = new ImageRegistry();
|
|
308
|
+
const latest = await imageRegistry.imageRegistryContract.getLatestTrustedZoneImageCertPublicKey(projectName, version);
|
|
309
|
+
const session = await imageRegistry.imageRegistryContract.getTrustedZoneImageSession(latest[0]);
|
|
310
|
+
console.log(session);
|
|
311
|
+
return session;
|
|
312
|
+
}
|
|
304
313
|
|
|
305
314
|
const imageRegistry = new ImageRegistry();
|
|
306
315
|
if (action === 'registerSecureLockImage') {
|
package/pynithy/run.js
CHANGED
|
@@ -5,6 +5,9 @@ const readline = require('readline');
|
|
|
5
5
|
const forge = require('node-forge');
|
|
6
6
|
const axios = require('axios');
|
|
7
7
|
const https = require('https');
|
|
8
|
+
const casConfig = require('../cas/config.js');
|
|
9
|
+
const casResolver = require('../cas/resolver.js');
|
|
10
|
+
const sessionRegistry = require('../cas/session_registry.js');
|
|
8
11
|
|
|
9
12
|
if (!fs.existsSync('.env')) {
|
|
10
13
|
console.error("Error: .env file not found");
|
|
@@ -58,6 +61,12 @@ const writeEnv = (key, value) => {
|
|
|
58
61
|
let templateName = process.env.TRUSTED_ZONE_IMAGE || 'etny-pynithy-testnet';
|
|
59
62
|
|
|
60
63
|
const isMainnet = !templateName.includes('testnet');
|
|
64
|
+
// A testnet template with a SessionRegistry is CAS-attested by the
|
|
65
|
+
// ethernity-cas validator set: the session is registered on-chain, the
|
|
66
|
+
// enclave is provisioned from a validator, and the certificate comes out of
|
|
67
|
+
// the CAS session. Other testnets self-sign and register nothing.
|
|
68
|
+
const casTestnet = !isMainnet && casConfig.casProvisioned(templateName, isMainnet);
|
|
69
|
+
const casChain = casConfig.CHAIN[templateName] || {};
|
|
61
70
|
|
|
62
71
|
const currentDir = process.cwd();
|
|
63
72
|
console.log(`currentDir: ${currentDir}`);
|
|
@@ -121,7 +130,7 @@ const main = async () => {
|
|
|
121
130
|
// MRENCLAVE signed --production at build time (/signed_mrenclave.txt). A
|
|
122
131
|
// mismatch means SCONE recomputed the measurement at load (params drift) and
|
|
123
132
|
// re-signed as DEBUG, which CAS rejects on mainnet. Refuse to publish.
|
|
124
|
-
if (isMainnet) {
|
|
133
|
+
if (isMainnet || casTestnet) {
|
|
125
134
|
const signedMrenclave = extractSignedMrenclave('etny-securelock');
|
|
126
135
|
if (!signedMrenclave || signedMrenclave !== process.env.MRENCLAVE_SECURELOCK) {
|
|
127
136
|
console.error(`Error: securelock runtime MRENCLAVE (${process.env.MRENCLAVE_SECURELOCK}) != signed MRENCLAVE (${signedMrenclave}).`);
|
|
@@ -151,9 +160,16 @@ const main = async () => {
|
|
|
151
160
|
const regex = new RegExp(`__${key}__`, 'g');
|
|
152
161
|
content = content.replace(regex, value);
|
|
153
162
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
163
|
+
// Mainnet attests production enclaves only; a testnet tolerates a
|
|
164
|
+
// debug-signed one. The tolerate list was edited by text replacement
|
|
165
|
+
// ('", debug-mode"'), which never matched this template's LEADING
|
|
166
|
+
// "debug-mode," -- so mainnet sessions were published tolerating debug
|
|
167
|
+
// enclaves. Written out per network instead.
|
|
168
|
+
content = content
|
|
169
|
+
.replace(/__TOLERATE__/g, isMainnet
|
|
170
|
+
? '[hyperthreading, outdated-tcb, software-hardening-needed]'
|
|
171
|
+
: '[debug-mode, hyperthreading, outdated-tcb, software-hardening-needed]')
|
|
172
|
+
.replace(/__IGNORE_ADVISORIES__/g, isMainnet ? '["INTEL-SA-00615"]' : '["*"]');
|
|
157
173
|
|
|
158
174
|
fs.writeFileSync(outputFile, content);
|
|
159
175
|
// console.log(`Contents of ${outputFile}:`);
|
|
@@ -204,7 +220,25 @@ const main = async () => {
|
|
|
204
220
|
};
|
|
205
221
|
processYamlTemplate('etny-securelock-test.yaml.tpl', 'etny-securelock-test.yaml', replacementsSecurelock);
|
|
206
222
|
|
|
207
|
-
|
|
223
|
+
// Where the session goes depends on who provisions the securelock. On a
|
|
224
|
+
// CAS-attested testnet it is registered ON-CHAIN in the ethernity-cas
|
|
225
|
+
// SessionRegistry, which the validator set reads and which accepts no
|
|
226
|
+
// POST; the publish then waits until the validators serve it, because the
|
|
227
|
+
// public-key harvest below provisions the enclave from them. Every other
|
|
228
|
+
// network keeps the Scontain CAS registration that follows.
|
|
229
|
+
if (casTestnet) {
|
|
230
|
+
const registered = await sessionRegistry.register(
|
|
231
|
+
casChain.rpcUrl, casChain.chainId, casConfig.SESSION_REGISTRY[templateName],
|
|
232
|
+
process.env.PRIVATE_KEY, fs.readFileSync('etny-securelock-test.yaml'),
|
|
233
|
+
process.env.IPFS_ENDPOINT, '');
|
|
234
|
+
if (registered.registered) {
|
|
235
|
+
console.log(`\t✔ Session ${registered.name} registered on-chain (body ${registered.cid})`);
|
|
236
|
+
await sessionRegistry.waitVisible(
|
|
237
|
+
casChain.rpcUrl, casConfig.SESSION_REGISTRY[templateName], registered.name, registered.hash);
|
|
238
|
+
} else {
|
|
239
|
+
console.log(`\t✔ Session ${registered.name} already registered on-chain with this body (${registered.hash})`);
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
208
242
|
// don't generate new keys if PREDECESSOR_HASH_SECURELOCK is not empty and the key.pem and cert.pem files exist
|
|
209
243
|
if (PREDECESSOR_HASH_SECURELOCK !== 'EMPTY' && fs.existsSync('key.pem') && fs.existsSync('cert.pem')) {
|
|
210
244
|
console.log("Skipping key pair generation and certificate creation.");
|
|
@@ -318,6 +352,7 @@ const main = async () => {
|
|
|
318
352
|
console.log("Please change the name/version of your project (using ecld-init or by editing .env file) and run the scripts again. Exiting.");
|
|
319
353
|
process.exit(1);
|
|
320
354
|
});
|
|
355
|
+
}
|
|
321
356
|
|
|
322
357
|
|
|
323
358
|
// const ENCLAVE_NAME_TRUSTEDZONE = generateEnclaveName(process.env.ENCLAVE_NAME_TRUSTEDZONE);
|
|
@@ -352,6 +387,23 @@ const main = async () => {
|
|
|
352
387
|
|
|
353
388
|
console.log("# Update docker-compose files");
|
|
354
389
|
|
|
390
|
+
// On a CAS-attested testnet the compose names the CAS both enclaves are
|
|
391
|
+
// provisioned from (a validator resolved from the registry, or
|
|
392
|
+
// ECLD_CAS_ADDR; the node re-resolves it before each task) and the
|
|
393
|
+
// trustedzone session the ImageRegistry records for this template.
|
|
394
|
+
let casAddr = null;
|
|
395
|
+
let trustedZoneSession = null;
|
|
396
|
+
if (casTestnet) {
|
|
397
|
+
casAddr = await casResolver.casAddressFor(casChain.rpcUrl, casConfig.VALIDATOR_REGISTRY[templateName]);
|
|
398
|
+
console.log(`\t✔ CAS for this network: ${casAddr}`);
|
|
399
|
+
trustedZoneSession = execSync(`node ./image_registry.js "${process.env.BLOCKCHAIN_NETWORK}" ${templateName} "v3" "" "getTrustedZoneSession"`).toString().trim();
|
|
400
|
+
if (!trustedZoneSession) {
|
|
401
|
+
console.error(`ERROR! The ImageRegistry records no trustedzone session for ${templateName}.`);
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
console.log(`\t✔ Trustedzone session: ${trustedZoneSession}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
355
407
|
const files = ['docker-compose.yml', 'docker-compose-final.yml'];
|
|
356
408
|
|
|
357
409
|
files.forEach(file => {
|
|
@@ -381,9 +433,15 @@ const main = async () => {
|
|
|
381
433
|
if (isMainnet) {
|
|
382
434
|
ENCLAVE_NAME_TRUSTEDZONE = 'ecld-pynithy-trustedzone-v3-3.0.0'
|
|
383
435
|
}
|
|
384
|
-
|
|
436
|
+
if (casTestnet) {
|
|
437
|
+
ENCLAVE_NAME_TRUSTEDZONE = trustedZoneSession;
|
|
438
|
+
}
|
|
439
|
+
let updatedContent = fileContentBefore
|
|
385
440
|
.replace(/__ENCLAVE_NAME_SECURELOCK__/g, ENCLAVE_NAME_SECURELOCK)
|
|
386
441
|
.replace(/__ENCLAVE_NAME_TRUSTEDZONE__/g, ENCLAVE_NAME_TRUSTEDZONE);
|
|
442
|
+
if (casTestnet) {
|
|
443
|
+
updatedContent = updatedContent.replace(/SCONE_CAS_ADDR=scone-cas\.cf/g, `SCONE_CAS_ADDR=${casAddr}`);
|
|
444
|
+
}
|
|
387
445
|
|
|
388
446
|
fs.writeFileSync(file, updatedContent, 'utf8');
|
|
389
447
|
|
|
@@ -570,6 +628,14 @@ const main = async () => {
|
|
|
570
628
|
if (!existing) {
|
|
571
629
|
const res = execSync(`node ${runDir}/image_registry.js "${process.env.BLOCKCHAIN_NETWORK}" "" "" "" "registerSecureLockImage"`, { stdio: "inherit" });
|
|
572
630
|
}
|
|
631
|
+
// The on-chain session points at the image it admits, so a validator can
|
|
632
|
+
// pin the image beside the body it serves.
|
|
633
|
+
if (casTestnet) {
|
|
634
|
+
await sessionRegistry.linkImage(
|
|
635
|
+
casChain.rpcUrl, casChain.chainId, casConfig.SESSION_REGISTRY[templateName],
|
|
636
|
+
process.env.PRIVATE_KEY, ENCLAVE_NAME_SECURELOCK, process.env.IPFS_HASH);
|
|
637
|
+
console.log(`\t✔ Session ${ENCLAVE_NAME_SECURELOCK} linked to image ${process.env.IPFS_HASH}`);
|
|
638
|
+
}
|
|
573
639
|
console.log("Script completed successfully. You can start testing the application now. (eg. npm run start)");
|
|
574
640
|
process.exit(0);
|
|
575
641
|
};
|