@ethernity-cloud/sdk-js 1.7.1 → 1.7.8
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 +30 -5
- package/cas/config.js +2 -2
- package/cas/resolver.js +49 -18
- package/cas/session_registry.js +72 -24
- package/info.js +1 -1
- package/init.js +13 -0
- package/localKubo.js +205 -0
- package/nodenithy/build/securelock/src/etny_crypto.js +20 -2
- package/nodenithy/build/securelock/src/models.js +3 -5
- package/nodenithy/build/securelock/src/securelock.js.tmpl +136 -70
- package/nodenithy/build.mjs +8 -7
- package/nodenithy/ipfs.mjs +37 -13
- package/nodenithy/run/image_registry.abi +1 -1
- package/nodenithy/run/image_registry.js +258 -68
- package/nodenithy/run/public_key_service.js +10 -3
- package/nodenithy/run/public_key_service.py +12 -6
- package/nodenithy/run.js +164 -88
- package/package.json +5 -2
- package/publish.js +20 -8
- package/pynithy/build/securelock/src/securelock.py.tmpl +21 -9
- package/pynithy/build.mjs +8 -7
- package/pynithy/ipfs.mjs +37 -13
- package/pynithy/run/image_registry.abi +1 -1
- package/pynithy/run/image_registry.js +258 -68
- package/pynithy/run/public_key_service.js +10 -3
- package/pynithy/run.js +164 -88
- package/revert.js +76 -0
package/README.md
CHANGED
|
@@ -41,13 +41,17 @@ The sdk has been tested on the following operating systems:
|
|
|
41
41
|
- Bloxberg:
|
|
42
42
|
- Testnet - tested and working. CAS-attested: the securelock is
|
|
43
43
|
provisioned by the ethernity-cas validator set exactly as on mainnet.
|
|
44
|
-
`ecld-publish` registers the
|
|
45
|
-
|
|
44
|
+
`ecld-publish` registers the image in the ImageRegistry
|
|
45
|
+
(ECImageRegistryV3, `0xa372a6e1...`) before anything discloses its
|
|
46
|
+
name, which makes the name the publishing wallet's; then registers the
|
|
47
|
+
securelock session on-chain in the SessionRegistry (`0x44442968...`, see
|
|
48
|
+
`cas/config.js`), which takes it only from that wallet, resolves a CAS
|
|
46
49
|
from the ValidatorRegistry (`0x0014aB10...`, or `ECLD_CAS_ADDR=host:port`)
|
|
47
50
|
for the compose and the certificate harvest, takes the trustedzone
|
|
48
|
-
session name from the ImageRegistry
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
session name from the ImageRegistry, and links the session to the
|
|
52
|
+
published image. `ecld-build` signs the securelock `--production`.
|
|
53
|
+
The certificate is harvested on an SGX host or, without one, extracted
|
|
54
|
+
by publickey.ethernity.cloud (see "How the image reaches the network").
|
|
51
55
|
- Testnet Unsafe - the unsafe network beside the testnet (see below).
|
|
52
56
|
- Mainnet - to be provided during the following updates
|
|
53
57
|
- LitVM:
|
|
@@ -108,6 +112,27 @@ sets the enclave heap (default `1024M`).
|
|
|
108
112
|
npm run ecld-publish
|
|
109
113
|
```
|
|
110
114
|
Required after build, to build and integrate the secure certificates that will be used during executions and to register the project to the Ethernity Cloud Image Register.
|
|
115
|
+
`DEVELOPER_FEE` in `.env` is your fee as the image's publisher, in percent of
|
|
116
|
+
each task's base price (the node's price): the network adds it to what the
|
|
117
|
+
dApp user pays and pays it to the image's reward address when the task is
|
|
118
|
+
validated. Default 0; the registry accepts up to 15, and the fee is fixed
|
|
119
|
+
once the image is registered. The reward address is the publishing wallet
|
|
120
|
+
unless `REWARD_ADDRESS` names another one; the image's owner can change it
|
|
121
|
+
later with the registry's `changeImageRewardAddress`.
|
|
122
|
+
|
|
123
|
+
How the image reaches the network: unless `.env` names an IPFS API of your
|
|
124
|
+
own (`IPFS_ENDPOINT` other than the public `ipfs.ethernity.cloud`), the
|
|
125
|
+
publish runs its own IPFS node in docker (`ipfs/kubo`), adds the image and
|
|
126
|
+
its compose there and peers it with the network's bootnode, which pins
|
|
127
|
+
every registered image. On a network whose image registry records images
|
|
128
|
+
before their certificate (the bloxberg testnet), the publish registers the
|
|
129
|
+
image from your wallet first (`registerImage`, with your node's address),
|
|
130
|
+
has the certificate extracted (on your SGX host, or by
|
|
131
|
+
publickey.ethernity.cloud, which fetches the image from your node or the
|
|
132
|
+
bootnode and reports its progress) and writes it from the same wallet
|
|
133
|
+
(`setImageCert`). Keep the publish running until it prints the registration:
|
|
134
|
+
your node is the image's first source. `ECLD_KUBO_SWARM_PORT` (default
|
|
135
|
+
4001) is the port your node listens on when it is free.
|
|
111
136
|
|
|
112
137
|
- **Test (local, no chain)**: To run your backend locally with the enclave's own
|
|
113
138
|
executor — no SGX, no gas, instant — run:
|
package/cas/config.js
CHANGED
|
@@ -21,8 +21,8 @@ const VALIDATOR_REGISTRY = {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
const SESSION_REGISTRY = {
|
|
24
|
-
'etny-pynithy-testnet': '
|
|
25
|
-
'etny-nodenithy-testnet': '
|
|
24
|
+
'etny-pynithy-testnet': '0x444429681fCb26470Cb12B5436E48402cF7478A3',
|
|
25
|
+
'etny-nodenithy-testnet': '0x444429681fCb26470Cb12B5436E48402cF7478A3',
|
|
26
26
|
'ecld-pynithy-litvm-testnet': '0x8ad24b3F406A41a0F8D3440021792EB203957F43',
|
|
27
27
|
'ecld-nodenithy-litvm-testnet': '0x8ad24b3F406A41a0F8D3440021792EB203957F43',
|
|
28
28
|
};
|
package/cas/resolver.js
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
// The validators are enumerated from the ValidatorRegistry and their enclave
|
|
4
4
|
// endpoints read from the CasKeyStore the registry names via keyStore() --
|
|
5
5
|
// the same chain data the node's own resolver uses before every task. Only
|
|
6
|
-
// /dns4, /dns, /ip4 and /ip6 endpoints are
|
|
7
|
-
// developer's machine with no Tor proxy.
|
|
8
|
-
// REST port answered GET /validator/identity.
|
|
6
|
+
// /dns4, /dns, /ip4 and /ip6 endpoints are used: the SDK runs on a
|
|
7
|
+
// developer's machine with no Tor proxy. The CAS the publish dials is taken
|
|
8
|
+
// only after its REST port answered GET /validator/identity. The CAS the
|
|
9
|
+
// published compose names is the first active validator in registry order,
|
|
10
|
+
// unprobed: the node and the extraction service resolve a CAS again before
|
|
11
|
+
// they run the compose, and a rerun of the same publish renders the same
|
|
12
|
+
// compose while the validator set is unchanged.
|
|
9
13
|
//
|
|
10
14
|
// Multiaddrs advertise the ENCLAVE port; the REST port follows the pairing
|
|
11
15
|
// convention rest = 9081 + (enclave - 19765). Enclave ports outside
|
|
@@ -53,23 +57,33 @@ async function identityAnswers(host, restPort, timeoutMs) {
|
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
async function resolveCas(rpcUrl, registryAddress, probeTimeoutMs = 10000, startAt = null) {
|
|
60
|
+
// The ValidatorRegistry, the CasKeyStore it names and its validator count.
|
|
61
|
+
async function registry(rpcUrl, registryAddress) {
|
|
59
62
|
const provider = new ethers.providers.JsonRpcProvider({ url: rpcUrl, timeout: 30000 });
|
|
60
63
|
const reg = new ethers.Contract(registryAddress, REGISTRY_ABI, provider);
|
|
61
64
|
const total = (await reg.validatorCount()).toNumber();
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
const store = total ? new ethers.Contract(await reg.keyStore(), STORE_ABI, provider) : null;
|
|
66
|
+
return { reg, store, total };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The usable endpoints of validator `index` as {host, port} (the ENCLAVE
|
|
70
|
+
// port), or none when it is not an active validator.
|
|
71
|
+
async function endpointsOf({ reg, store }, index) {
|
|
72
|
+
const v = await reg.validatorSet(index);
|
|
73
|
+
if (!(await reg.isValidator(v))) return [];
|
|
74
|
+
return (await store.multiaddrsOf(v)).map(parseMultiaddr).filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// "host:port" (the ENCLAVE port) of the first active validator whose REST port
|
|
78
|
+
// answers, sweeping from a rotating offset; null when none answered.
|
|
79
|
+
async function resolveCas(rpcUrl, registryAddress, probeTimeoutMs = 10000, startAt = null) {
|
|
80
|
+
const set = await registry(rpcUrl, registryAddress);
|
|
81
|
+
if (set.total === 0) return null;
|
|
82
|
+
const offset = startAt === null ? Math.floor(Math.random() * set.total) : startAt % set.total;
|
|
83
|
+
for (let step = 0; step < set.total; step++) {
|
|
84
|
+
for (const { host, port } of await endpointsOf(set, (offset + step) % set.total)) {
|
|
85
|
+
if (await identityAnswers(host, restPortFor(port), probeTimeoutMs)) {
|
|
86
|
+
return `${host}:${port}`;
|
|
73
87
|
}
|
|
74
88
|
}
|
|
75
89
|
}
|
|
@@ -92,4 +106,21 @@ async function casAddressFor(rpcUrl, registryAddress) {
|
|
|
92
106
|
return resolved;
|
|
93
107
|
}
|
|
94
108
|
|
|
95
|
-
|
|
109
|
+
// The CAS the published compose names: ECLD_CAS_ADDR when set, else
|
|
110
|
+
// "host:port" (the ENCLAVE port) of the first active validator in registry
|
|
111
|
+
// order, unprobed. Throws when neither yields one.
|
|
112
|
+
async function publishedCasAddress(rpcUrl, registryAddress) {
|
|
113
|
+
const override = String(process.env.ECLD_CAS_ADDR || '').trim();
|
|
114
|
+
if (override) return override;
|
|
115
|
+
if (!registryAddress) {
|
|
116
|
+
throw new Error('no ValidatorRegistry is known for this network and ECLD_CAS_ADDR is not set');
|
|
117
|
+
}
|
|
118
|
+
const set = await registry(rpcUrl, registryAddress);
|
|
119
|
+
for (let index = 0; index < set.total; index++) {
|
|
120
|
+
const [first] = await endpointsOf(set, index);
|
|
121
|
+
if (first) return `${first.host}:${first.port}`;
|
|
122
|
+
}
|
|
123
|
+
throw new Error(`no active validator in ${registryAddress} publishes a usable endpoint`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { parseMultiaddr, restPortFor, identityAnswers, resolveCas, casAddressFor, publishedCasAddress };
|
package/cas/session_registry.js
CHANGED
|
@@ -14,11 +14,21 @@
|
|
|
14
14
|
const crypto = require('crypto');
|
|
15
15
|
const axios = require('axios');
|
|
16
16
|
const { ethers } = require('ethers');
|
|
17
|
+
const { revertReason } = require('../revert.js');
|
|
17
18
|
|
|
19
|
+
// `latest` and `record` name a version by its record key: a record id
|
|
20
|
+
// (keccak256(abi.encode(name, sessionHash))), or the body's hash on the
|
|
21
|
+
// registries that key records by it. The record holds the body's hash on both.
|
|
18
22
|
const ABI = [
|
|
19
23
|
'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
24
|
'function latest(string name) view returns (bytes32)',
|
|
21
|
-
'function
|
|
25
|
+
'function record(bytes32 id) view returns (bytes32 sessionHash, address creator, string enclaveName, uint32 version, string bodyCid, string imageCid, uint8 hashAlgo, uint64 registeredAt, bool exists)',
|
|
26
|
+
'function linkImage(bytes32 id, string imageCid)',
|
|
27
|
+
'error NotCreator(address creator, address caller)',
|
|
28
|
+
'error DuplicateSession(bytes32 recordId)',
|
|
29
|
+
'error NotImageNameOwner(string project, address nameOwner, address caller)',
|
|
30
|
+
'error NotAllowedPublisher(address caller)',
|
|
31
|
+
'error AmbiguousSecurelockName(string name)',
|
|
22
32
|
];
|
|
23
33
|
|
|
24
34
|
const HASH_ALGO_SHA256 = 1;
|
|
@@ -78,52 +88,90 @@ function contract(rpcUrl, registryAddress, key) {
|
|
|
78
88
|
return new ethers.Contract(registryAddress, ABI, signer);
|
|
79
89
|
}
|
|
80
90
|
|
|
91
|
+
// The latest version of `name`: {id, hash, creator}, or null when it has none.
|
|
92
|
+
async function latestRecord(reg, name) {
|
|
93
|
+
const id = await reg.latest(name);
|
|
94
|
+
if (/^0x0+$/.test(id)) return null;
|
|
95
|
+
const rec = await reg.record(id);
|
|
96
|
+
return { id, hash: rec.sessionHash.toLowerCase(), creator: rec.creator };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Send `method(...args)` to the registry. It is simulated first, so a call
|
|
100
|
+
// the registry refuses stops with the registry's reason (`refused` names the
|
|
101
|
+
// call), then sent with its gas estimate plus 30%: a registration's cost
|
|
102
|
+
// grows with the length of the name. The wallet signs for the chain its RPC
|
|
103
|
+
// serves (ethers takes no chainId override on a contract call), so an RPC
|
|
104
|
+
// serving another chain than `chainId` is refused before anything is sent.
|
|
105
|
+
async function send(reg, chainId, refused, method, ...args) {
|
|
106
|
+
const { chainId: served } = await reg.provider.getNetwork();
|
|
107
|
+
if (served !== chainId) {
|
|
108
|
+
throw new Error(`SessionRegistry: the RPC serves chain ${served}, not ${chainId}`);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await reg.callStatic[method](...args);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
throw new Error(`SessionRegistry refuses ${refused}: ${revertReason(e, reg.interface)}`);
|
|
114
|
+
}
|
|
115
|
+
const gas = await reg.estimateGas[method](...args);
|
|
116
|
+
const tx = await reg[method](...args, {
|
|
117
|
+
gasLimit: gas.mul(13).div(10), gasPrice: ethers.utils.parseUnits('1', 'mwei'),
|
|
118
|
+
});
|
|
119
|
+
const rcpt = await tx.wait();
|
|
120
|
+
if (rcpt.status !== 1) throw new Error(`SessionRegistry.${method} reverted for ${refused} (tx ${tx.hash})`);
|
|
121
|
+
}
|
|
122
|
+
|
|
81
123
|
// Pin `body` and register it under its own `name:`. Returns
|
|
82
124
|
// {name, hash, cid, registered}: `registered` is false when the chain already
|
|
83
|
-
// held these exact bytes as the name's latest version
|
|
125
|
+
// held these exact bytes as the name's latest version, registered by this
|
|
126
|
+
// wallet.
|
|
84
127
|
async function register(rpcUrl, chainId, registryAddress, key, body, ipfsApiUrl, imageCid = '') {
|
|
85
128
|
const { name, rules } = parseNameAndRules(body.toString('utf8'));
|
|
86
129
|
if (!name) throw new Error('the session body has no `name:`');
|
|
87
130
|
const hash = sessionHash(body);
|
|
88
131
|
const reg = contract(rpcUrl, registryAddress, key);
|
|
89
132
|
const cid = await pinBody(ipfsApiUrl, body);
|
|
90
|
-
|
|
133
|
+
const latest = await latestRecord(reg, name);
|
|
134
|
+
if (latest && latest.hash === hash && latest.creator.toLowerCase() === (await reg.signer.getAddress()).toLowerCase()) {
|
|
91
135
|
return { name, hash, cid, registered: false };
|
|
92
136
|
}
|
|
93
|
-
|
|
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
|
-
}
|
|
137
|
+
await send(reg, chainId, name, 'register', hash, name, cid, imageCid, HASH_ALGO_SHA256, rules);
|
|
100
138
|
return { name, hash, cid, registered: true };
|
|
101
139
|
}
|
|
102
140
|
|
|
103
|
-
// Point the name's latest version at the published image CID.
|
|
104
|
-
|
|
141
|
+
// Point the name's latest version at the published image CID. A node behind
|
|
142
|
+
// the chain can answer that a name registered minutes earlier has no record,
|
|
143
|
+
// so the name is read again every 15 seconds for `waitMs` before it is called
|
|
144
|
+
// unregistered.
|
|
145
|
+
async function linkImage(rpcUrl, chainId, registryAddress, key, name, imageCid, waitMs = 600000) {
|
|
105
146
|
const reg = contract(rpcUrl, registryAddress, key);
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
147
|
+
const deadline = Date.now() + waitMs;
|
|
148
|
+
let latest = await latestRecord(reg, name);
|
|
149
|
+
while (!latest) {
|
|
150
|
+
if (Date.now() >= deadline) throw new Error(`no registered session named ${name}`);
|
|
151
|
+
await sleep(15000);
|
|
152
|
+
latest = await latestRecord(reg, name);
|
|
153
|
+
}
|
|
154
|
+
await send(reg, chainId, `the link of ${name}`, 'linkImage', latest.id, imageCid);
|
|
111
155
|
}
|
|
112
156
|
|
|
113
157
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
114
158
|
|
|
115
|
-
// Block until the chain reports `name` at `hash`,
|
|
116
|
-
// validators pick it up: the elected writer's poll
|
|
117
|
-
// 60 s) notices the registration and generates the
|
|
118
|
-
// transaction confirms, and the other validators' next
|
|
119
|
-
// recorded blob. The enclave cannot retry and the validator
|
|
120
|
-
// node's choice, so the wait covers the LAST validator to
|
|
159
|
+
// Block until the chain reports `name`'s latest version at body hash `hash`,
|
|
160
|
+
// then hold while the validators pick it up: the elected writer's poll
|
|
161
|
+
// (ECAS_SESSION_WATCH_SECS, 60 s) notices the registration and generates the
|
|
162
|
+
// secrets, its recordKey transaction confirms, and the other validators' next
|
|
163
|
+
// poll adopts the recorded blob. The enclave cannot retry and the validator
|
|
164
|
+
// it dials is the node's choice, so the wait covers the LAST validator to
|
|
165
|
+
// adopt.
|
|
121
166
|
async function waitVisible(rpcUrl, registryAddress, name, hash, timeoutMs = 180000) {
|
|
122
167
|
const reg = contract(rpcUrl, registryAddress, null);
|
|
123
168
|
const deadline = Date.now() + timeoutMs;
|
|
124
169
|
for (;;) {
|
|
125
170
|
let got;
|
|
126
|
-
try {
|
|
171
|
+
try {
|
|
172
|
+
const latest = await latestRecord(reg, name);
|
|
173
|
+
got = latest ? latest.hash : '(none)';
|
|
174
|
+
} catch (e) { got = `(read failed: ${e.message})`; }
|
|
127
175
|
if (got === hash.toLowerCase()) {
|
|
128
176
|
console.log(`\t✔ session ${name} visible on chain at ${got}`);
|
|
129
177
|
break;
|
package/info.js
CHANGED
|
@@ -46,7 +46,7 @@ const NETWORKS = {
|
|
|
46
46
|
BLOXBERG_TESTNET: {
|
|
47
47
|
type: 'testnet', chainId: 8995, rpc: 'https://bloxberg.ethernity.cloud',
|
|
48
48
|
protocol: '0x02882F03097fE8cD31afbdFbB5D72a498B41112c',
|
|
49
|
-
imageRegistry: '
|
|
49
|
+
imageRegistry: '0xa372a6e1Eb7Fcf343AF6b91E809b900C55001CD1',
|
|
50
50
|
esr: '0x0Ea1728EAE108FD3B9340ae91451348E2Cc6b4E4',
|
|
51
51
|
},
|
|
52
52
|
LITVM_LITEFORGE: {
|
package/init.js
CHANGED
|
@@ -28,6 +28,14 @@ const writeEnv = (key, value) => {
|
|
|
28
28
|
fs.writeFileSync(envFile, content, "utf8");
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
// The project name is the securelock's image name, and <name>-unsafe on an
|
|
32
|
+
// -unsafe network. ECImageRegistryV3 takes 1 to 128 of [0-9A-Za-z._-], so a
|
|
33
|
+
// project name is at most 121 of them; spaces are written as '_'. It also
|
|
34
|
+
// begins the securelock's session name, <name>_SECURELOCK_V3_..., which the
|
|
35
|
+
// SessionRegistry refuses when "_SECURELOCK_" occurs in it twice, so a project
|
|
36
|
+
// name does not contain "SECURELOCK".
|
|
37
|
+
const PROJECT_NAME_RULE = /^(?!.*SECURELOCK)[0-9A-Za-z._-]{1,121}$/;
|
|
38
|
+
|
|
31
39
|
const getProjectName = () => {
|
|
32
40
|
return new Promise((resolve) => {
|
|
33
41
|
const askProjectName = () => {
|
|
@@ -37,6 +45,11 @@ const getProjectName = () => {
|
|
|
37
45
|
"Project name cannot be blank. Please enter a valid name.",
|
|
38
46
|
);
|
|
39
47
|
askProjectName();
|
|
48
|
+
} else if (!PROJECT_NAME_RULE.test(projectName.trim().replace(/ /g, "_"))) {
|
|
49
|
+
console.log(
|
|
50
|
+
"A project name is 1 to 121 letters, digits, '.', '-' or '_' (a space becomes '_'), and does not contain SECURELOCK. Please enter a valid name.",
|
|
51
|
+
);
|
|
52
|
+
askProjectName();
|
|
40
53
|
} else {
|
|
41
54
|
console.log(`You have chosen the project name: ${projectName}`);
|
|
42
55
|
resolve(projectName);
|
package/localKubo.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// The Kubo a publish runs for its own upload.
|
|
2
|
+
//
|
|
3
|
+
// An image is published by adding it to IPFS and registering its CID. The
|
|
4
|
+
// public write API of ipfs.ethernity.cloud is closing, so a publish without an
|
|
5
|
+
// IPFS endpoint of its own runs a Kubo in docker (the SDK already requires
|
|
6
|
+
// docker), adds the image through that Kubo's HTTP API with the same call the
|
|
7
|
+
// public API received (so a build gives the same CIDs as before), peers the
|
|
8
|
+
// Kubo with the bootnode, whose mirror pins every registered image and from
|
|
9
|
+
// which the certificate extraction service fetches, and stops it once the
|
|
10
|
+
// certificate is on chain. The Kubo listens for the API on the loopback
|
|
11
|
+
// interface only; its swarm port is published when free, so a publisher with
|
|
12
|
+
// a reachable address is also dialed directly.
|
|
13
|
+
const { execFileSync } = require('child_process');
|
|
14
|
+
const net = require('net');
|
|
15
|
+
const axios = require('axios');
|
|
16
|
+
|
|
17
|
+
const KUBO_IMAGE = process.env.ECLD_KUBO_IMAGE || 'ipfs/kubo:release';
|
|
18
|
+
// The bootnode's IPFS node (ipfs.ethernity.cloud), as the node agents peer with it.
|
|
19
|
+
const BOOTNODE_MULTIADDR = '/dns4/ipfs.ethernity.cloud/tcp/4001/p2p/QmRBc1eBt4hpJQUqHqn6eA8ixQPD3LFcUDsn6coKBQtia5';
|
|
20
|
+
const SWARM_PORT = Number(process.env.ECLD_KUBO_SWARM_PORT || 4001);
|
|
21
|
+
const API_READY_MS = 90000;
|
|
22
|
+
// The public IPFS API: an IPFS_ENDPOINT naming it is not an endpoint of the
|
|
23
|
+
// application's own, so the publish runs its own Kubo instead.
|
|
24
|
+
const PUBLIC_IPFS_HOST = 'ipfs.ethernity.cloud';
|
|
25
|
+
|
|
26
|
+
const PRIVATE = /^\/ip4\/(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|0\.0\.0\.0|169\.254\.)|^\/ip6\/(::1|fe80|fc|fd)/;
|
|
27
|
+
|
|
28
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
|
|
30
|
+
function portFree(port) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const server = net.createServer();
|
|
33
|
+
server.once('error', () => resolve(false));
|
|
34
|
+
server.listen(port, '0.0.0.0', () => server.close(() => resolve(true)));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function freePort() {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const server = net.createServer();
|
|
41
|
+
server.once('error', reject);
|
|
42
|
+
server.listen(0, '127.0.0.1', () => {
|
|
43
|
+
const { port } = server.address();
|
|
44
|
+
server.close(() => resolve(port));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The host port in the first line of `docker port <container> 5001/tcp`
|
|
50
|
+
// (`127.0.0.1:60823`), or null when there is none.
|
|
51
|
+
function apiPortIn(dockerPortOutput) {
|
|
52
|
+
const first = String(dockerPortOutput || '').trim().split('\n')[0] || '';
|
|
53
|
+
const match = /:(\d+)$/.exec(first.trim());
|
|
54
|
+
return match ? Number(match[1]) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Whether the configured endpoint is one of the application's own; the public
|
|
58
|
+
// API and an empty setting both mean "run a Kubo for this publish".
|
|
59
|
+
function ownEndpoint(endpoint) {
|
|
60
|
+
if (!endpoint) return false;
|
|
61
|
+
try {
|
|
62
|
+
return new URL(endpoint).hostname !== PUBLIC_IPFS_HOST;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class LocalKubo {
|
|
69
|
+
constructor(name) {
|
|
70
|
+
this.container = `ecld-kubo-${String(name || 'publish').replace(/[^A-Za-z0-9_.-]/g, '-')}`;
|
|
71
|
+
this.peers = (process.env.ECLD_IPFS_PEERS || BOOTNODE_MULTIADDR).split(',').map((p) => p.trim()).filter(Boolean);
|
|
72
|
+
this.apiUrl = null;
|
|
73
|
+
// True while a registered image has this Kubo as its source: from the
|
|
74
|
+
// image's registration on chain until its certificate is on chain. A
|
|
75
|
+
// publish that ends in between leaves the Kubo running (release).
|
|
76
|
+
this.serving = false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async api(command, params, timeout) {
|
|
80
|
+
const response = await axios.post(`${this.apiUrl}/api/v0/${command}`, null, { params, timeout: timeout || 30000 });
|
|
81
|
+
return response.data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The API port of the container a previous publish of this project left
|
|
85
|
+
// running (release), or null when none runs.
|
|
86
|
+
keptApiPort() {
|
|
87
|
+
try {
|
|
88
|
+
return apiPortIn(execFileSync('docker', ['port', this.container, '5001/tcp'], { stdio: ['ignore', 'pipe', 'ignore'] }));
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Start the container and wait for its API, or take over the one a
|
|
95
|
+
// previous publish of this project left serving its registered image,
|
|
96
|
+
// whose peer id that image's registry entry names; peer it with the
|
|
97
|
+
// bootnode. Throws when docker cannot run it or the API does not come up
|
|
98
|
+
// in time.
|
|
99
|
+
async start() {
|
|
100
|
+
const kept = this.keptApiPort();
|
|
101
|
+
if (kept !== null) {
|
|
102
|
+
this.apiUrl = `http://127.0.0.1:${kept}`;
|
|
103
|
+
console.log(`\t✔ IPFS node of the previous publish kept: ${this.container}`);
|
|
104
|
+
} else {
|
|
105
|
+
const apiPort = await freePort();
|
|
106
|
+
this.apiUrl = `http://127.0.0.1:${apiPort}`;
|
|
107
|
+
try { execFileSync('docker', ['rm', '-f', this.container], { stdio: 'ignore' }); } catch (e) { /* not running */ }
|
|
108
|
+
const args = ['run', '-d', '--name', this.container, '-p', `127.0.0.1:${apiPort}:5001`];
|
|
109
|
+
if (await portFree(SWARM_PORT)) {
|
|
110
|
+
args.push('-p', `${SWARM_PORT}:4001`, '-p', `${SWARM_PORT}:4001/udp`);
|
|
111
|
+
}
|
|
112
|
+
args.push(KUBO_IMAGE, 'daemon', '--init', '--migrate=true');
|
|
113
|
+
try {
|
|
114
|
+
execFileSync('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
115
|
+
} catch (e) {
|
|
116
|
+
throw new Error(`docker could not start ${KUBO_IMAGE}: ${String(e.stderr || e.message).trim()}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const deadline = Date.now() + API_READY_MS;
|
|
120
|
+
for (;;) {
|
|
121
|
+
try {
|
|
122
|
+
await this.api('id', {}, 5000);
|
|
123
|
+
break;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
if (Date.now() > deadline) {
|
|
126
|
+
this.stop();
|
|
127
|
+
throw new Error(`the Kubo API did not come up in ${API_READY_MS / 1000}s`);
|
|
128
|
+
}
|
|
129
|
+
await sleep(2000);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// The peering service dials each peer and keeps the connection; the
|
|
133
|
+
// connect is waited for, not forced, since the service's own dial and
|
|
134
|
+
// a swarm/connect of the same peer refuse each other.
|
|
135
|
+
for (const peer of this.peers) {
|
|
136
|
+
try {
|
|
137
|
+
await this.api('swarm/peering/add', { arg: peer }, 15000);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.log(`\t⚠ could not peer with ${peer}: ${e.message}`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const until = Date.now() + 30000;
|
|
143
|
+
while (!(await this.connectedTo(peer)) && Date.now() < until) {
|
|
144
|
+
await sleep(1000);
|
|
145
|
+
}
|
|
146
|
+
if (!(await this.connectedTo(peer))) {
|
|
147
|
+
console.log(`\t⚠ not connected to ${peer} yet; the peering service keeps trying`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// How this Kubo is registered on chain: its first public address with the
|
|
153
|
+
// peer id, or /p2p/<id> when it has none a stranger could dial (the
|
|
154
|
+
// bootnode reaches it over the connection it opened).
|
|
155
|
+
async peerMultiaddr() {
|
|
156
|
+
const identity = await this.api('id', {});
|
|
157
|
+
const peerId = identity.ID;
|
|
158
|
+
for (const address of identity.Addresses || []) {
|
|
159
|
+
if (!PRIVATE.test(address) && !address.includes('/p2p-circuit') && address.includes(`/p2p/${peerId}`)) {
|
|
160
|
+
return address;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return `/p2p/${peerId}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Announce `cid` to the DHT so a node not connected to this one can find
|
|
167
|
+
// it. Best effort.
|
|
168
|
+
async provide(cid) {
|
|
169
|
+
try {
|
|
170
|
+
await axios.post(`${this.apiUrl}/api/v0/routing/provide`, null, { params: { arg: cid }, timeout: 180000 });
|
|
171
|
+
} catch (e) {
|
|
172
|
+
console.log(`\t⚠ could not announce ${cid}: ${e.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async connectedTo(peerMultiaddr) {
|
|
177
|
+
const peerId = peerMultiaddr.split('/p2p/').pop();
|
|
178
|
+
try {
|
|
179
|
+
const peers = (await this.api('swarm/peers', {})).Peers || [];
|
|
180
|
+
return peers.some((p) => p.Peer === peerId);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
stop() {
|
|
187
|
+
try { execFileSync('docker', ['rm', '-f', this.container], { stdio: 'ignore' }); } catch (e) { /* already gone */ }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// End the publish's use of the Kubo: stop it, unless it is the source of
|
|
191
|
+
// a registered image whose certificate is not on chain yet, which it keeps
|
|
192
|
+
// serving to the extraction service and the bootnode's mirror; the next
|
|
193
|
+
// publish of the project takes it over, `docker rm -f` stops it.
|
|
194
|
+
release() {
|
|
195
|
+
if (!this.serving) {
|
|
196
|
+
this.stop();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
console.log(`\t⚠ The registered image stays available from this publish's IPFS node, the docker`);
|
|
200
|
+
console.log(`\t container ${this.container}, for the extraction service and the bootnode.`);
|
|
201
|
+
console.log(`\t Publish again to retry; \`docker rm -f ${this.container}\` stops it.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = { LocalKubo, ownEndpoint, apiPortIn, PUBLIC_IPFS_HOST };
|
|
@@ -63,8 +63,23 @@ const generatePublicKey = (x, y) => {
|
|
|
63
63
|
return curve.keyFromPublic(pub, 'hex');
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// A ciphertext tuple's coordinate as elliptic reads it: hex without a prefix.
|
|
67
|
+
// runner-py writes the 0x prefix, runner-js and these enclaves write none.
|
|
68
|
+
const bareHex = (v) => String(v).trim().replace(/^0x/i, '');
|
|
69
|
+
|
|
66
70
|
function getPublicPointFromCoords(x, y) {
|
|
67
|
-
const publicKey = generatePublicKey(x, y);
|
|
71
|
+
const publicKey = generatePublicKey(bareHex(x), bareHex(y));
|
|
72
|
+
// The coordinates come from an untrusted ciphertext tuple. keyFromPublic
|
|
73
|
+
// builds the point with no on-curve check, and elliptic's add/double
|
|
74
|
+
// arithmetic never uses the curve's b, so an off-curve or small-order
|
|
75
|
+
// point multiplied by the identity scalar would leak that scalar modulo
|
|
76
|
+
// the point's order through the AES-GCM tag oracle. validate() rejects
|
|
77
|
+
// the point at infinity, any point not on P-384 (the b-dependent check),
|
|
78
|
+
// and any point whose order is not the group order.
|
|
79
|
+
const check = publicKey.validate();
|
|
80
|
+
if (!check.result) {
|
|
81
|
+
throw new Error('ECIES public point rejected: ' + check.reason);
|
|
82
|
+
}
|
|
68
83
|
return publicKey.getPublic();
|
|
69
84
|
}
|
|
70
85
|
|
|
@@ -138,7 +153,10 @@ function decrypt_ecc(encryptedMsg, privatePoint) {
|
|
|
138
153
|
decrypted += decipher.final('utf8');
|
|
139
154
|
return decrypted;
|
|
140
155
|
} catch (e) {
|
|
141
|
-
|
|
156
|
+
// Opaque on purpose: the failure cause (rejected point vs. tag
|
|
157
|
+
// mismatch) is not logged, so the caller of the decrypt sees only
|
|
158
|
+
// success or failure and gets no oracle on the identity key.
|
|
159
|
+
console.log('Error while decrypting ECC');
|
|
142
160
|
return false;
|
|
143
161
|
}
|
|
144
162
|
}
|
|
@@ -27,9 +27,8 @@ class MetadataBase {
|
|
|
27
27
|
throw new Error("Subclass must implement this method");
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
// True when
|
|
31
|
-
//
|
|
32
|
-
// without inspecting raw metadata strings themselves.
|
|
30
|
+
// True when the metadata records a checksum the content is validated
|
|
31
|
+
// against.
|
|
33
32
|
get has_checksum() {
|
|
34
33
|
return this.checksum != null;
|
|
35
34
|
}
|
|
@@ -193,8 +192,7 @@ class InputMetadataV2 extends MetadataBase {
|
|
|
193
192
|
class InputMetadataV3 extends MetadataBase {
|
|
194
193
|
constructor(metadata) {
|
|
195
194
|
super(metadata, "v3");
|
|
196
|
-
//
|
|
197
|
-
// null so `has_checksum` is false and validation is skipped.
|
|
195
|
+
// An absent or "0" checksum field is normalized to null.
|
|
198
196
|
const c = (metadata.split(":")[2] || "").trim();
|
|
199
197
|
this._checksum = (c && c !== "0") ? c : null;
|
|
200
198
|
this._ipfs_hash = metadata.split(":")[1];
|