@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 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
+ };