@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 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 securelock session on-chain in the
45
- SessionRegistry (`0xcb1F389b...`, see `cas/config.js`), resolves a CAS
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 (ECImageRegistryV2,
49
- `0x99A84C62...`), and links the session to the published image. `ecld-build` signs the securelock `--production`.
50
- Harvesting the certificate needs SGX.
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': '0xcb1F389bF4524d1D61EDcbC24eC1F1F9C3FF4Fa6',
25
- 'etny-nodenithy-testnet': '0xcb1F389bF4524d1D61EDcbC24eC1F1F9C3FF4Fa6',
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 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.
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
- // "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) {
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
- 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}`;
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
- module.exports = { parseMultiaddr, restPortFor, identityAnswers, resolveCas, casAddressFor };
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 };
@@ -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 linkImage(bytes32 sessionHash, string imageCid)',
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
- if ((await reg.latest(name)).toLowerCase() === hash) {
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
- 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
- }
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
- async function linkImage(rpcUrl, chainId, registryAddress, key, name, imageCid) {
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 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})`);
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`, 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.
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 { got = (await reg.latest(name)).toLowerCase(); } catch (e) { got = `(read failed: ${e.message})`; }
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: '0x99A84C624C028bdf0a855A1E9E3f2fcf7275B3D8',
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
- console.log(`Error while decrypting ECC:`, e.message);
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 this metadata carries a real checksum to validate. Empty input
31
- // carries no checksum (checksum is null), so callers can skip validation
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
- // Empty input carries an empty (or "0") checksum field; normalize it to
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];