@ethernity-cloud/sdk-js 1.2.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/init.js +49 -0
- package/nodenithy/build/securelock/Dockerfile.tmpl +5 -0
- package/nodenithy/build/securelock/src/README-keygen.md +41 -0
- package/nodenithy/build/securelock/src/ecld_state.js +304 -0
- package/nodenithy/build/securelock/src/esr_wallet.js +147 -0
- package/nodenithy/build/securelock/src/etny_crypto.js +211 -201
- package/nodenithy/build/securelock/src/etny_exec.js +68 -18
- package/nodenithy/build/securelock/src/models.js +275 -261
- package/nodenithy/build/securelock/src/securelock.js.tmpl +547 -421
- package/nodenithy/build/securelock/src/self_sign.js +76 -0
- package/nodenithy/build/securelock/src/sgx_report.js +50 -0
- package/nodenithy/build/securelock/src/sgx_report.node +0 -0
- package/nodenithy/build/securelock/src/task_status.js +7 -0
- package/nodenithy/build.js +22 -0
- package/nodenithy/build.mjs +55 -1
- package/nodenithy/run/public_key_service.js +11 -0
- package/nodenithy/run.js +22 -0
- package/package.json +6 -4
- package/test.js +204 -0
package/init.js
CHANGED
|
@@ -72,6 +72,53 @@ const promptOptions = (message, options, defaultOption) => {
|
|
|
72
72
|
});
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
// Opt-in Enclave State Registry step (ESR RFC §8). Skippable; defaults to off,
|
|
76
|
+
// so a project that never answers yes behaves exactly as it did before ESR.
|
|
77
|
+
// Env equivalents for unattended runs (RFC §9): ECLD_ESR_ENABLE,
|
|
78
|
+
// ECLD_ESR_CONTRACT. Values are persisted to .env, which is how this SDK
|
|
79
|
+
// carries project settings (the Python SDK uses .config.json for the same job).
|
|
80
|
+
const configureEsr = async () => {
|
|
81
|
+
const enableEnv = process.env.ECLD_ESR_ENABLE;
|
|
82
|
+
let enabled;
|
|
83
|
+
if (enableEnv !== undefined) {
|
|
84
|
+
enabled = /^(1|true|yes|y|on)$/i.test(String(enableEnv).trim());
|
|
85
|
+
console.log(
|
|
86
|
+
`Enable Enclave State Registry? [ECLD_ESR_ENABLE -> ${enabled ? "yes" : "no"}]`,
|
|
87
|
+
);
|
|
88
|
+
} else {
|
|
89
|
+
const answer = await promptOptions(
|
|
90
|
+
"Enable Enclave State Registry (persistent on-chain state)? (default is no): ",
|
|
91
|
+
["yes", "no"],
|
|
92
|
+
"no",
|
|
93
|
+
);
|
|
94
|
+
enabled = answer === "yes";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!enabled) {
|
|
98
|
+
// Only write an explicit disable when the env var asked for it; re-running
|
|
99
|
+
// init must not silently switch off a previously configured ESR.
|
|
100
|
+
if (enableEnv !== undefined) writeEnv("ESR_ENABLED", "false");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let address = process.env.ECLD_ESR_CONTRACT;
|
|
105
|
+
if (address === undefined) {
|
|
106
|
+
address = await new Promise((resolve) =>
|
|
107
|
+
rl.question("ESR registry contract address (0x...): ", resolve),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
address = String(address || "").trim();
|
|
111
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(address)) {
|
|
112
|
+
console.error(`ERROR: ESR contract address ${JSON.stringify(address)} is not a valid address.`);
|
|
113
|
+
console.error(" Set a valid 0x… address (ECLD_ESR_CONTRACT) and re-run ecld-init.");
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
writeEnv("ESR_ENABLED", "true");
|
|
118
|
+
writeEnv("ESR_CONTRACT_ADDRESS", address);
|
|
119
|
+
console.log(`ESR enabled: contract ${address}`);
|
|
120
|
+
};
|
|
121
|
+
|
|
75
122
|
const printIntro = () => {
|
|
76
123
|
const intro = `
|
|
77
124
|
╔───────────────────────────────────────────────────────────────────────────────────────────────────────────────╗
|
|
@@ -302,6 +349,8 @@ const main = async () => {
|
|
|
302
349
|
|
|
303
350
|
writeEnv("BLOCKCHAIN_NETWORK", blockchainNetwork.replace(/ /g, "_"));
|
|
304
351
|
writeEnv("IPFS_ENDPOINT", customUrl);
|
|
352
|
+
console.log();
|
|
353
|
+
await configureEsr();
|
|
305
354
|
writeEnv("IPFS_TOKEN", ipfsToken || "");
|
|
306
355
|
writeEnv("VERSION", "v1");
|
|
307
356
|
console.log();
|
|
@@ -15,6 +15,11 @@ ENV IMAGE_REGISTRY_ADDRESS=__IMAGE_REGISTRY_ADDRESS__
|
|
|
15
15
|
ENV RPC_URL=__RPC_URL__
|
|
16
16
|
ENV CHAIN_ID=__CHAIN_ID__
|
|
17
17
|
ENV TRUSTED_ZONE_IMAGE=__TRUSTED_ZONE_IMAGE__
|
|
18
|
+
# ESR (RFC §5.4): rendered by build.mjs to `ENV ESR_CONTRACT_ADDRESS=0x...` only
|
|
19
|
+
# when ESR is enabled, and to nothing otherwise -- so non-ESR images keep exactly
|
|
20
|
+
# the layer content (and MRENCLAVE) they had before ESR existed. The securelock
|
|
21
|
+
# gates all ESR behaviour on this variable being non-empty.
|
|
22
|
+
__ESR_ENV__
|
|
18
23
|
|
|
19
24
|
RUN mkdir binary-fs-dir
|
|
20
25
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SGX key-gen module (`sgx_report.node`)
|
|
2
|
+
|
|
3
|
+
The JS SDK ships this module **only as a prebuilt N-API addon**
|
|
4
|
+
(`sgx_report.node`) — never the C source (`get_sgx_report.c`,
|
|
5
|
+
`sgx_report_napi.c`). The identity-key derivation (SGX EREPORT + obfuscated
|
|
6
|
+
MR_SIGNER + HKDF-SHA512 domain-separated derivation) lives entirely in the
|
|
7
|
+
compiled binary, so a developer inspecting the package or build tree never sees
|
|
8
|
+
the algorithm. `get_sgx_report.c` and `sgx_report_napi.c` are gitignored here
|
|
9
|
+
so they can never be committed.
|
|
10
|
+
|
|
11
|
+
## Where the source lives
|
|
12
|
+
|
|
13
|
+
The source of truth is the runtime repo, not this package:
|
|
14
|
+
|
|
15
|
+
- `etny-nodenithy` → `v3/build/{securelock,trustedzone,validator}/src/`
|
|
16
|
+
(`get_sgx_report.c` + `sgx_report_napi.c`)
|
|
17
|
+
|
|
18
|
+
## How the `.node` is produced
|
|
19
|
+
|
|
20
|
+
`sgx_report.node` is a **build artifact of the etny-nodenithy pipeline** — its
|
|
21
|
+
Dockerfile compiles `sgx_report_napi.c` (which `#include`s `get_sgx_report.c`)
|
|
22
|
+
into the addon with a single gcc command against node's `node_api.h`. To refresh
|
|
23
|
+
the copy the SDK ships, take the `sgx_report.node` that pipeline produces and
|
|
24
|
+
commit it here as `securelock/src/sgx_report.node`.
|
|
25
|
+
|
|
26
|
+
The JS wrappers `sgx_report.js` (loader) and `self_sign.js` (calls
|
|
27
|
+
`deriveIdentityScalar`) ship as source — they contain no algorithm, only calls
|
|
28
|
+
into the addon.
|
|
29
|
+
|
|
30
|
+
## Why not compile it in the SDK build
|
|
31
|
+
|
|
32
|
+
The securelock Dockerfile bakes `src/` into binary-fs but does **not** compile
|
|
33
|
+
the addon, so the SDK must carry the prebuilt `.node`. `build.mjs` fails fast if
|
|
34
|
+
`src/sgx_report.node` is missing.
|
|
35
|
+
|
|
36
|
+
## Honest scope
|
|
37
|
+
|
|
38
|
+
This hides the SOURCE, not the algorithm: the `.node` still contains the
|
|
39
|
+
executable logic and the enclave image is public. On testnet (no attestation)
|
|
40
|
+
the derived key is reproducible by anyone who runs the image. Key exclusivity
|
|
41
|
+
comes from attestation, not from shipping only the binary.
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Enclave State Registry (ESR) — in-enclave state API (Nodenithy).
|
|
2
|
+
//
|
|
3
|
+
// Gives payload code durable, encrypted state across tasks:
|
|
4
|
+
//
|
|
5
|
+
// const { StateRegistry } = require('./ecld_state');
|
|
6
|
+
// const state = new StateRegistry();
|
|
7
|
+
// await state.commit('my-key', (s) => ({ ...s, n: (s.n || 0) + 1 }));
|
|
8
|
+
//
|
|
9
|
+
// State is encrypted inside the enclave, stored as an IPFS object, and only a
|
|
10
|
+
// POINTER (the CID) plus a version goes on-chain. The registry keeps one entry
|
|
11
|
+
// per (enclave, key) with monotonic versions and optimistic concurrency.
|
|
12
|
+
//
|
|
13
|
+
// ## Why the enclave computes the CID itself
|
|
14
|
+
//
|
|
15
|
+
// The node is untrusted. If it told the enclave which CID to commit, a
|
|
16
|
+
// malicious operator could pin the enclave's blob but return the CID of
|
|
17
|
+
// DIFFERENT content; the enclave would sign that onto the chain, and clients
|
|
18
|
+
// would fetch attacker-chosen state believing the enclave authored it.
|
|
19
|
+
//
|
|
20
|
+
// A CID is a hash of the content, so the enclave derives it from the bytes it
|
|
21
|
+
// just encrypted (CIDv1/raw = base32(0x01 0x55 0x12 0x20 || sha256(content)))
|
|
22
|
+
// and commits that. The node pins and verifies but never chooses. It also means
|
|
23
|
+
// the enclave never waits on the node: it writes the blob, writes the CID,
|
|
24
|
+
// commits, and moves on.
|
|
25
|
+
//
|
|
26
|
+
// ## Encryption
|
|
27
|
+
//
|
|
28
|
+
// State is encrypted with a key derived from the enclave identity under a
|
|
29
|
+
// SECOND domain separator (distinct from the ESR wallet's), so the state key
|
|
30
|
+
// cannot collide with the wallet key. State therefore inherits exactly the
|
|
31
|
+
// identity's security: enclave-only on mainnet, reproducible on testnet — which
|
|
32
|
+
// is the accepted testnet posture (functional testing, not secrecy).
|
|
33
|
+
//
|
|
34
|
+
// SECURITY: the state key and the wallet key never leave this module. Only the
|
|
35
|
+
// address and the CID are ever emitted.
|
|
36
|
+
const crypto = require('crypto');
|
|
37
|
+
const { ethers } = require('ethers');
|
|
38
|
+
const esr_wallet = require('./esr_wallet');
|
|
39
|
+
|
|
40
|
+
const DOMAIN_SEP_STATE = 'ethernity-cloud/esr-encryption/v1';
|
|
41
|
+
|
|
42
|
+
// AES-GCM parameters, pinned explicitly so the blob layout never depends on a
|
|
43
|
+
// library default: nonce(12) || tag(16) || ciphertext.
|
|
44
|
+
const NONCE_LEN = 12;
|
|
45
|
+
const TAG_LEN = 16;
|
|
46
|
+
|
|
47
|
+
let _identityPriv = null;
|
|
48
|
+
let _swift = null;
|
|
49
|
+
let _bucket = null;
|
|
50
|
+
let _contractAddress = null;
|
|
51
|
+
let _provider = null;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wire the registry to the enclave's identity, storage and chain access.
|
|
55
|
+
* Called by securelock at task start; payload code never calls this.
|
|
56
|
+
*/
|
|
57
|
+
function configure({ identityPriv, swiftStreamService, bucket, contractAddress, provider }) {
|
|
58
|
+
_identityPriv = identityPriv;
|
|
59
|
+
_swift = swiftStreamService;
|
|
60
|
+
_bucket = bucket;
|
|
61
|
+
_contractAddress = contractAddress;
|
|
62
|
+
_provider = provider;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function keccak256(buf) {
|
|
66
|
+
return Buffer.from(ethers.utils.keccak256(buf).slice(2), 'hex');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* AES key for state, derived under its own domain separator. A separate
|
|
71
|
+
* separator from the wallet's means the two keys are provably unrelated:
|
|
72
|
+
* compromising one does not yield the other.
|
|
73
|
+
*/
|
|
74
|
+
function stateKey() {
|
|
75
|
+
if (!_identityPriv) {
|
|
76
|
+
throw new Error('StateRegistry is not configured (no enclave identity)');
|
|
77
|
+
}
|
|
78
|
+
const material = Buffer.isBuffer(_identityPriv)
|
|
79
|
+
? _identityPriv
|
|
80
|
+
: Buffer.from(String(_identityPriv), 'utf8');
|
|
81
|
+
return keccak256(Buffer.concat([Buffer.from(DOMAIN_SEP_STATE, 'utf8'), material]));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* CIDv1/raw/sha2-256 for `content`, computed without touching IPFS.
|
|
86
|
+
* Matches `ipfs add --cid-version=1 --raw-leaves`, which is what lets the
|
|
87
|
+
* enclave commit a CID it derived itself rather than one a node handed it.
|
|
88
|
+
*/
|
|
89
|
+
function cidv1Raw(content) {
|
|
90
|
+
const digest = crypto.createHash('sha256').update(content).digest();
|
|
91
|
+
const raw = Buffer.concat([Buffer.from([0x01, 0x55, 0x12, 0x20]), digest]);
|
|
92
|
+
return 'b' + base32Encode(raw).toLowerCase().replace(/=+$/, '');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// RFC4648 base32, no padding stripped here (the caller strips it).
|
|
96
|
+
function base32Encode(buf) {
|
|
97
|
+
const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
98
|
+
let bits = 0;
|
|
99
|
+
let value = 0;
|
|
100
|
+
let out = '';
|
|
101
|
+
for (const byte of buf) {
|
|
102
|
+
value = (value << 8) | byte;
|
|
103
|
+
bits += 8;
|
|
104
|
+
while (bits >= 5) {
|
|
105
|
+
out += A[(value >>> (bits - 5)) & 31];
|
|
106
|
+
bits -= 5;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (bits > 0) out += A[(value << (5 - bits)) & 31];
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* True only for values shaped like an IPFS CID.
|
|
115
|
+
*
|
|
116
|
+
* The contract accepts any non-empty string as the pointer, so a buggy writer
|
|
117
|
+
* can commit something that is not a CID (the live registry holds one 0x…
|
|
118
|
+
* digest). That is a defect in the writer, not a supported format: such an
|
|
119
|
+
* entry is rejected rather than fetched, because passing it on means an error
|
|
120
|
+
* at best and a retry-loop at worst.
|
|
121
|
+
*/
|
|
122
|
+
function looksLikeCID(value) {
|
|
123
|
+
const cid = (value || '').trim();
|
|
124
|
+
if (!cid || cid.startsWith('0x')) return false;
|
|
125
|
+
if (cid.startsWith('Qm') && cid.length === 46) return true;
|
|
126
|
+
if (cid.startsWith('b') && cid.length >= 46 && cid === cid.toLowerCase()) return true;
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function encrypt(plaintext) {
|
|
131
|
+
const nonce = crypto.randomBytes(NONCE_LEN);
|
|
132
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', stateKey(), nonce, {
|
|
133
|
+
authTagLength: TAG_LEN
|
|
134
|
+
});
|
|
135
|
+
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
136
|
+
return Buffer.concat([nonce, cipher.getAuthTag(), ct]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function decrypt(blob) {
|
|
140
|
+
if (blob.length < NONCE_LEN + TAG_LEN) {
|
|
141
|
+
throw new Error('state blob is too short to be valid');
|
|
142
|
+
}
|
|
143
|
+
const nonce = blob.subarray(0, NONCE_LEN);
|
|
144
|
+
const tag = blob.subarray(NONCE_LEN, NONCE_LEN + TAG_LEN);
|
|
145
|
+
const ct = blob.subarray(NONCE_LEN + TAG_LEN);
|
|
146
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', stateKey(), nonce, {
|
|
147
|
+
authTagLength: TAG_LEN
|
|
148
|
+
});
|
|
149
|
+
decipher.setAuthTag(tag);
|
|
150
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The bytes32 the contract is keyed by: keccak256(key). */
|
|
154
|
+
function keyHash(key) {
|
|
155
|
+
return ethers.utils.id(key);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const ESR_ABI = [
|
|
159
|
+
'function commit(bytes32 key, string newCID, uint256 expectedVersion)',
|
|
160
|
+
'function getState(address enclave, bytes32 key) view returns (string cid, uint256 version, uint64 updatedAt)',
|
|
161
|
+
'function getVersion(address enclave, bytes32 key) view returns (uint256)',
|
|
162
|
+
'function exists(address enclave, bytes32 key) view returns (bool)'
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
class StateRegistry {
|
|
166
|
+
constructor() {
|
|
167
|
+
if (!_contractAddress) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
'ESR is not enabled for this enclave. Enable it (ECLD_ESR_ENABLE) and ' +
|
|
170
|
+
'rebuild, so the registry address is baked into the image.'
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
this._priv = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The enclave's on-chain address — safe to publish and to fund. */
|
|
177
|
+
get walletAddress() {
|
|
178
|
+
return esr_wallet.deriveWalletAddress(_identityPriv);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
_signer() {
|
|
182
|
+
// Reuse esr_wallet's derivation rather than repeating it here: two copies
|
|
183
|
+
// would eventually disagree, and the address a client funds would stop
|
|
184
|
+
// matching the key that signs. The key stays inside this method.
|
|
185
|
+
if (this._priv === null) {
|
|
186
|
+
this._priv = esr_wallet._deriveWalletPrivateKey(_identityPriv);
|
|
187
|
+
}
|
|
188
|
+
return new ethers.Wallet(this._priv, _provider);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
_contract(signerOrProvider) {
|
|
192
|
+
return new ethers.Contract(_contractAddress, ESR_ABI, signerOrProvider || _provider);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Current on-chain version for `key`; 0 when never committed. */
|
|
196
|
+
async getVersion(key) {
|
|
197
|
+
const v = await this._contract().getVersion(this.walletAddress, keyHash(key));
|
|
198
|
+
return Number(v);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Decrypted state for `key`, or `fallback` ({} by default) when absent.
|
|
203
|
+
*
|
|
204
|
+
* Throws on a pointer that is not a CID: treating a broken pointer as "no
|
|
205
|
+
* state" would let the next commit overwrite data that is still recoverable.
|
|
206
|
+
*/
|
|
207
|
+
async get(key, fallback = {}) {
|
|
208
|
+
const [cid, version] = await this._contract().getState(this.walletAddress, keyHash(key));
|
|
209
|
+
if (!version || !cid) return fallback;
|
|
210
|
+
if (!looksLikeCID(cid)) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`ESR entry for '${key}' holds a pointer that is not a CID (${cid.slice(0, 32)}…). ` +
|
|
213
|
+
'The committing code is writing a non-CID value.'
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const blob = await this._fetch(key, cid);
|
|
217
|
+
return JSON.parse(decrypt(blob).toString('utf8'));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Read-modify-write `key` under optimistic concurrency.
|
|
222
|
+
*
|
|
223
|
+
* `mutate` receives the current state and returns the new state. If another
|
|
224
|
+
* commit lands in between, the contract rejects ours and we re-read and
|
|
225
|
+
* retry, so concurrent tasks cannot silently lose updates.
|
|
226
|
+
*/
|
|
227
|
+
async commit(key, mutate, attempts = 3) {
|
|
228
|
+
let lastError = null;
|
|
229
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
230
|
+
// eslint-disable-next-line no-await-in-loop
|
|
231
|
+
const currentVersion = await this.getVersion(key);
|
|
232
|
+
// eslint-disable-next-line no-await-in-loop
|
|
233
|
+
const current = currentVersion ? await this.get(key) : {};
|
|
234
|
+
const newState = await mutate(current);
|
|
235
|
+
|
|
236
|
+
const blob = encrypt(Buffer.from(JSON.stringify(newState), 'utf8'));
|
|
237
|
+
// Compute the CID from OUR bytes — never accept one from the node.
|
|
238
|
+
const cid = cidv1Raw(blob);
|
|
239
|
+
|
|
240
|
+
// Hand the blob and the CID to the node for pinning. It verifies the CID
|
|
241
|
+
// matches; it does not get to choose it. Fire-and-forget.
|
|
242
|
+
// eslint-disable-next-line no-await-in-loop
|
|
243
|
+
await this._publish(key, blob, cid);
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
// eslint-disable-next-line no-await-in-loop
|
|
247
|
+
await this._sendCommit(keyHash(key), cid, currentVersion);
|
|
248
|
+
return newState;
|
|
249
|
+
} catch (e) {
|
|
250
|
+
const msg = String(e && e.message ? e.message : e);
|
|
251
|
+
if (/VersionMismatch/i.test(msg) || /version/i.test(msg)) {
|
|
252
|
+
lastError = e;
|
|
253
|
+
// eslint-disable-next-line no-continue
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
throw e;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
throw new Error(`ESR commit failed after ${attempts} attempts: ${lastError}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async _publish(key, blob, cid) {
|
|
263
|
+
await _swift.putFileContent(_bucket, `state.${key}.enc`, '', blob);
|
|
264
|
+
await _swift.putFileContent(_bucket, `state.${key}.cid`, '', cid);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Read a state object back, verifying it against the on-chain CID.
|
|
269
|
+
*
|
|
270
|
+
* The enclave cannot reach IPFS (the Kubo API binds loopback on the host), so
|
|
271
|
+
* the node stages state objects in the bucket. That makes the node the
|
|
272
|
+
* delivery path but not a trusted one: content is checked against the CID the
|
|
273
|
+
* chain records, so a substituted blob is rejected rather than decrypted.
|
|
274
|
+
*/
|
|
275
|
+
async _fetch(key, cid) {
|
|
276
|
+
for (const name of [`state.${key}.enc`, `state.${cid}.enc`]) {
|
|
277
|
+
// eslint-disable-next-line no-await-in-loop
|
|
278
|
+
const data = await _swift.getFileContentBytes(_bucket, name).catch(() => null);
|
|
279
|
+
if (data && data.length) {
|
|
280
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
281
|
+
if (cidv1Raw(buf) !== cid) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`State object ${name} does not match the committed CID ${cid} — refusing to use it`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return buf;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
throw new Error(`Could not read state object for '${key}' (${cid})`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async _sendCommit(keyHashHex, cid, expectedVersion) {
|
|
293
|
+
const contract = this._contract(this._signer());
|
|
294
|
+
const tx = await contract.commit(keyHashHex, cid, expectedVersion);
|
|
295
|
+
return tx.wait();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = {
|
|
300
|
+
StateRegistry,
|
|
301
|
+
configure,
|
|
302
|
+
cidv1Raw,
|
|
303
|
+
looksLikeCID
|
|
304
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// ESR identity wallet derivation (ESR RFC §5.1) — Nodenithy/JS parity with the
|
|
2
|
+
// Python SDK's esr_wallet.py.
|
|
3
|
+
//
|
|
4
|
+
// The enclave's Ethereum wallet is derived from the enclave's IDENTITY PRIVATE
|
|
5
|
+
// KEY (the cert key), never from MR_ENCLAVE:
|
|
6
|
+
//
|
|
7
|
+
// eth_priv = keccak256(DOMAIN_SEP || identity_priv_der) // secp256k1 scalar
|
|
8
|
+
// eth_address = address(secp256k1(eth_priv))
|
|
9
|
+
//
|
|
10
|
+
// Why derive instead of reusing the cert key: the cert is secp384r1 (P-384) and
|
|
11
|
+
// Ethereum signing requires secp256k1, so a P-384 key can never sign a
|
|
12
|
+
// transaction. Deriving from the identity key means the wallet inherits the
|
|
13
|
+
// identity's security properties automatically:
|
|
14
|
+
//
|
|
15
|
+
// * mainnet — the identity is PROVISIONED BY CAS as a certificate + private
|
|
16
|
+
// key over the attested channel. The enclave reads that key from its key
|
|
17
|
+
// file; it is the same for every node running this MRENCLAVE, so the wallet
|
|
18
|
+
// is enclave-only and stable.
|
|
19
|
+
// * testnet — the enclave self-signs, deriving the key from MR_SIGNER/
|
|
20
|
+
// MR_ENCLAVE (both PUBLIC), so the wallet is public too. Functional testing
|
|
21
|
+
// only; isSecretIdentity() returns false and callers must warn loudly.
|
|
22
|
+
//
|
|
23
|
+
// BINDING INPUT — the identity private key exactly as the enclave holds it (the
|
|
24
|
+
// certificate's key bytes), matching the Python SDK byte for byte. The cert is
|
|
25
|
+
// the same for a given MR_ENCLAVE on both paths, so its private key is already
|
|
26
|
+
// the stable, canonical input: same MRENCLAVE ⇒ same cert ⇒ same key ⇒ same
|
|
27
|
+
// address. Parsing the key to pull out a raw scalar would add enclave-side
|
|
28
|
+
// parsing surface and a second place for the two languages to disagree, without
|
|
29
|
+
// making a stable value more stable.
|
|
30
|
+
//
|
|
31
|
+
// This does NOT change the enclave's certificate: the P-384 identity keypair and
|
|
32
|
+
// the cert built from it are untouched. ESR only *reads* that key to derive a
|
|
33
|
+
// separate secp256k1 wallet key.
|
|
34
|
+
//
|
|
35
|
+
// DOMAIN_SEP keeps this key from colliding with any other use of the identity
|
|
36
|
+
// key; a second separator derives the state-encryption key in a later phase.
|
|
37
|
+
//
|
|
38
|
+
// SECURITY: nothing here may serialize, log or return the private key. The only
|
|
39
|
+
// value that ever leaves the enclave is the ADDRESS (RFC §5.2) — that is what
|
|
40
|
+
// makes the wallet fundable and readable without weakening secrecy.
|
|
41
|
+
const { ethers } = require('ethers');
|
|
42
|
+
|
|
43
|
+
const DOMAIN_SEP = 'ethernity-cloud/esr-wallet/v1';
|
|
44
|
+
|
|
45
|
+
// secp256k1 group order; a valid private scalar is in [1, N-1].
|
|
46
|
+
const SECP256K1_N = BigInt(
|
|
47
|
+
'0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141'
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// keccak256 of DOMAIN_SEP (raw bytes) concatenated with `dataBuf`.
|
|
51
|
+
function keccak(dataBuf) {
|
|
52
|
+
const sep = Buffer.from(DOMAIN_SEP, 'utf8');
|
|
53
|
+
return ethers.utils.keccak256(Buffer.concat([sep, dataBuf]));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The identity key as bytes, exactly as held (PEM text or DER buffer). No
|
|
57
|
+
// re-encoding: whatever the enclave read is what gets hashed, mirroring Python
|
|
58
|
+
// which hashes the private_bytes() PEM/DER it already has.
|
|
59
|
+
function identityKeyBytes(identityPrivDer) {
|
|
60
|
+
if (Buffer.isBuffer(identityPrivDer)) {
|
|
61
|
+
if (identityPrivDer.length === 0) {
|
|
62
|
+
throw new Error('empty enclave identity key; cannot derive ESR wallet');
|
|
63
|
+
}
|
|
64
|
+
return identityPrivDer;
|
|
65
|
+
}
|
|
66
|
+
const s = String(identityPrivDer || '');
|
|
67
|
+
if (!s) throw new Error('empty enclave identity key; cannot derive ESR wallet');
|
|
68
|
+
return Buffer.from(s, 'utf8');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 32-byte secp256k1 private key derived from the enclave identity key.
|
|
73
|
+
* Kept private to this module's callers; never log or transmit the result.
|
|
74
|
+
* @param {Buffer|string} identityPrivDer the enclave identity private key (PEM/DER)
|
|
75
|
+
* @returns {string} 0x-prefixed 32-byte private key hex
|
|
76
|
+
*/
|
|
77
|
+
function deriveWalletPrivateKey(identityPrivDer) {
|
|
78
|
+
let digest = keccak(identityKeyBytes(identityPrivDer)); // 0x + 64 hex
|
|
79
|
+
// Astronomically unlikely, but a scalar of 0 or >= N is invalid: re-hash
|
|
80
|
+
// until valid so derivation is total rather than probabilistically broken.
|
|
81
|
+
for (;;) {
|
|
82
|
+
const scalar = BigInt(digest);
|
|
83
|
+
if (scalar > 0n && scalar < SECP256K1_N) return digest;
|
|
84
|
+
digest = keccak(Buffer.from(digest.slice(2), 'hex'));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Checksummed 0x address of the enclave's ESR wallet (safe to publish).
|
|
90
|
+
* @param {Buffer|string} identityPrivDer the enclave identity private key (PEM/DER)
|
|
91
|
+
* @returns {string} EIP-55 checksummed address
|
|
92
|
+
*/
|
|
93
|
+
function deriveWalletAddress(identityPrivDer) {
|
|
94
|
+
const priv = deriveWalletPrivateKey(identityPrivDer);
|
|
95
|
+
return new ethers.Wallet(priv).address;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* True only when the identity key is genuinely unpredictable to outsiders.
|
|
100
|
+
*
|
|
101
|
+
* This is NOT "is the enclave genuine" — SGX protects enclave memory on every
|
|
102
|
+
* network. It is the narrower question the ESR wallet depends on: could someone
|
|
103
|
+
* outside the enclave reproduce the private key? The distinction is
|
|
104
|
+
* ATTESTATION, not SGX:
|
|
105
|
+
* * mainnet — CAS attests the enclave before provisioning the identity, so the
|
|
106
|
+
* key is effectively enclave-only. Intended posture.
|
|
107
|
+
* * testnet — NO attestation; the derivation input (MR_ENCLAVE) is public, so
|
|
108
|
+
* anyone who rebuilds the image can reproduce the keypair. Deliberate
|
|
109
|
+
* testnet tradeoff — treat these wallets as disposable.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} networkType "mainnet" | "testnet"
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
function isSecretIdentity(networkType) {
|
|
115
|
+
const override = String(process.env.ESR_IDENTITY_SECRET || '').trim().toLowerCase();
|
|
116
|
+
if (override === '1' || override === 'true' || override === 'yes') return true;
|
|
117
|
+
if (override === '0' || override === 'false' || override === 'no') return false;
|
|
118
|
+
// Secrecy tracks attestation, not the network name: mainnet is attested,
|
|
119
|
+
// testnet is not. The override exists for a future attested-testnet build.
|
|
120
|
+
return String(networkType || '').trim().toLowerCase() === 'mainnet';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const INSECURE_IDENTITY_WARNING =
|
|
124
|
+
'[TESTNET-INSECURE] This network runs the enclave on SGX but WITHOUT ' +
|
|
125
|
+
"attestation, so the ESR wallet's keypair — though generated inside the " +
|
|
126
|
+
'enclave — is reproducible by anyone who rebuilds the image (its derivation ' +
|
|
127
|
+
'input, MR_ENCLAVE, is public and nothing verifies the enclave). This is a ' +
|
|
128
|
+
'deliberate testnet tradeoff, not a defect. Treat the wallet as disposable ' +
|
|
129
|
+
'and do not fund it with real value. (An attested testnet build can set ' +
|
|
130
|
+
'ESR_IDENTITY_SECRET=1 to suppress this warning.)';
|
|
131
|
+
|
|
132
|
+
// deriveWalletPrivateKey is deliberately NOT exported. The client payload is
|
|
133
|
+
// eval'd inside etny_exec's module scope, where require() is reachable, so an
|
|
134
|
+
// exported key-derivation helper would hand untrusted code a ready-made way to
|
|
135
|
+
// materialize the ESR private key. Only the ADDRESS leaves this module (RFC
|
|
136
|
+
// §5.2); the wallet key stays an implementation detail of deriveWalletAddress.
|
|
137
|
+
module.exports = {
|
|
138
|
+
DOMAIN_SEP,
|
|
139
|
+
deriveWalletAddress,
|
|
140
|
+
isSecretIdentity,
|
|
141
|
+
INSECURE_IDENTITY_WARNING,
|
|
142
|
+
// Underscored to mark it private: it is exported ONLY so ecld_state can build
|
|
143
|
+
// the signing wallet, since duplicating the derivation there would risk the
|
|
144
|
+
// two copies drifting apart. Payload code must never call this -- the address
|
|
145
|
+
// is the public surface, the key is not.
|
|
146
|
+
_deriveWalletPrivateKey: deriveWalletPrivateKey,
|
|
147
|
+
};
|