@ethernity-cloud/sdk-js 1.6.1 → 1.7.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.
Files changed (52) hide show
  1. package/README.md +44 -6
  2. package/cas/config.js +61 -3
  3. package/info.js +333 -324
  4. package/init.js +20 -4
  5. package/nodenithy/build/securelock/Dockerfile.tmpl +34 -32
  6. package/nodenithy/build/securelock/scripts/binary-fs-build.sh +7 -6
  7. package/nodenithy/build/securelock/src/securelock.js.tmpl +5 -2
  8. package/nodenithy/build.mjs +83 -71
  9. package/nodenithy/run/docker-compose-final.yml.tmpl +36 -31
  10. package/nodenithy/run/docker-compose.yml.tmpl +34 -45
  11. package/nodenithy/run/image_registry.js +396 -380
  12. package/nodenithy/run.js +42 -28
  13. package/package.json +5 -3
  14. package/publish.js +4 -2
  15. package/pynithy/build/securelock/Dockerfile.base.tpl +13 -0
  16. package/pynithy/build/securelock/Dockerfile.tpl +131 -0
  17. package/pynithy/build/securelock/scripts/binary-fs-build.sh +25 -34
  18. package/pynithy/build/securelock/scripts/pyfreeze.sh +32 -0
  19. package/pynithy/build/securelock/src/{pox.abi → abi/etnyImplementationV2.abi} +1489 -1489
  20. package/pynithy/build/securelock/src/abi/image_registry.abi +1 -0
  21. package/pynithy/build/securelock/src/ecld_state.py +906 -0
  22. package/pynithy/build/securelock/src/esr.abi +1 -0
  23. package/pynithy/build/securelock/src/esr_local.py +267 -0
  24. package/pynithy/build/securelock/src/esr_wallet.py +150 -0
  25. package/pynithy/build/securelock/src/etny_crypto.py +208 -167
  26. package/pynithy/build/securelock/src/etny_exec.py +385 -79
  27. package/pynithy/build/securelock/src/etny_exec_flask.py +535 -0
  28. package/pynithy/build/securelock/src/etny_exec_serv.py +353 -0
  29. package/pynithy/build/securelock/src/etny_session_exec.py +245 -0
  30. package/pynithy/build/securelock/src/get_sgx_report.so +0 -0
  31. package/pynithy/build/securelock/src/image_registry.abi +1 -1
  32. package/pynithy/build/securelock/src/key_generation.py +17 -17
  33. package/pynithy/build/securelock/src/local_api.py +189 -0
  34. package/pynithy/build/securelock/src/models.py +200 -176
  35. package/pynithy/build/securelock/src/securelock.py.tmpl +1037 -727
  36. package/pynithy/build/securelock/src/swift_stream_service.py +206 -186
  37. package/pynithy/build.mjs +142 -92
  38. package/pynithy/run/docker-compose-final.yml.tmpl +58 -51
  39. package/pynithy/run/docker-compose.yml.tmpl +62 -48
  40. package/pynithy/run/etny-securelock-test.yaml.tpl +4 -1
  41. package/pynithy/run/image_registry.js +396 -380
  42. package/pynithy/run.js +44 -28
  43. package/pynithy/src/serverless/Dockerfile.serverless +8 -0
  44. package/pynithy/src/serverless/requirements.txt +6 -0
  45. package/run.js +69 -28
  46. package/nodenithy/build/las/Dockerfile +0 -5
  47. package/pynithy/build/docker-compose.yml +0 -70
  48. package/pynithy/build/las/Dockerfile +0 -11
  49. package/pynithy/build/las/src/etny_entrypoint.sh +0 -6
  50. package/pynithy/build/las/src/las_entrypoint.sh +0 -26
  51. package/pynithy/build/scripts/binary-fs-build.sh +0 -28
  52. package/pynithy/build/securelock/Dockerfile.tmpl +0 -95
@@ -0,0 +1,906 @@
1
+ """Enclave State Registry (ESR) — in-enclave state API.
2
+
3
+ Gives payload code durable, encrypted state across tasks:
4
+
5
+ from ecld_state import StateRegistry
6
+
7
+ state = StateRegistry()
8
+ data = state.get("my-key") # {} the first time
9
+ state.commit("my-key", lambda s: {**s, "n": s.get("n", 0) + 1})
10
+
11
+ State is encrypted inside the enclave, stored as an IPFS object, and only a
12
+ POINTER (the CID) plus a version goes on-chain. The registry contract keeps one
13
+ entry per (enclave, key) with monotonic versions and optimistic concurrency.
14
+
15
+ ## Why the enclave computes the CID itself
16
+
17
+ The node is untrusted. If it told the enclave which CID to commit, a malicious
18
+ operator could pin the enclave's blob but return the CID of DIFFERENT content;
19
+ the enclave would sign that onto the chain, and clients would fetch
20
+ attacker-chosen state believing the enclave authored it.
21
+
22
+ A CID is a hash of the content, so the enclave derives it from the bytes it just
23
+ encrypted (CIDv1/raw = base32(0x01 0x55 0x12 0x20 || sha256(content))) and
24
+ commits that. The node pins and verifies but never chooses. A hostile node can
25
+ refuse to pin or pin something else, yet cannot change what was committed:
26
+ substitution is impossible rather than merely detectable.
27
+
28
+ It also means the enclave never waits on the node — it writes the blob, writes
29
+ the CID, commits, and moves on. Pinning is fire-and-forget.
30
+
31
+ ## Encryption
32
+
33
+ State is encrypted with a key derived from the enclave identity under a SECOND
34
+ domain separator (distinct from the ESR wallet's), so the state key cannot
35
+ collide with the wallet key. State therefore inherits exactly the identity's
36
+ security: on mainnet it is CAS-provisioned and enclave-only; on testnet the
37
+ identity is reproducible, so state there is readable by anyone who can reproduce
38
+ it. That is the accepted testnet posture — functional testing, not secrecy.
39
+
40
+ SECURITY: the state encryption key and the wallet key never leave this module.
41
+ Only the address and the CID are ever emitted.
42
+ """
43
+
44
+ import base64
45
+ import hashlib
46
+ import io
47
+ import json
48
+ import os
49
+
50
+ DOMAIN_SEP_STATE = b"ethernity-cloud/esr-encryption/v1"
51
+
52
+ # Set by the securelock at startup: the enclave identity key (PEM/DER bytes) and
53
+ # the SwiftStream client + bucket the node shares with this enclave.
54
+ _identity_priv = None
55
+ _swift = None
56
+ _bucket = None
57
+ _contract_address = None
58
+ _web3 = None
59
+
60
+ # ESR commits do NOT pay their own gas (ESR RELAY-DESIGN.md): the enclave only
61
+ # SIGNS each commit (commitFor); the NODE relays it and pays, and does all gas
62
+ # accounting because it is the payer. The securelock does no gas math and holds
63
+ # no budget -- nothing it could compute about cost would be trusted anyway. It
64
+ # just records every signed authorization this order so the node can relay them
65
+ # and the trustedzone can independently price + adjudicate the whole set.
66
+ _esr_evidence = [] # order-wide ledger of signed authorizations
67
+
68
+ # Task-scoped ledger of every state key this execution touched, recorded by
69
+ # get()/commit(). ecld_result() snapshots it to attach the fresh state to the
70
+ # task result, so callers (and the runner's state cache) get current state in
71
+ # the same result -- no separate read task needed. Reset per task in configure().
72
+ _task_ledger = {}
73
+
74
+ # Per-task memo of the last idempotency nonce ACCEPTED per key, so a
75
+ # get_nonce() right after a commit in the SAME task returns the fresh value
76
+ # even though the node's relay (and thus the chain) has not landed yet.
77
+ _task_nonces = {}
78
+ # Per-task memo of the NEXT relay nonce per key hash (hex), so multiple
79
+ # commits to the same key within one task sign sequential relay nonces even
80
+ # while the node is still relaying the earlier ones.
81
+ _task_relay_nonces = {}
82
+
83
+
84
+ def _ledger_record(key, version, cid, state):
85
+ _task_ledger[key] = {
86
+ "key": key, "version": int(version), "cid": cid, "state": state}
87
+
88
+
89
+ def ledger_snapshot(include_state=True, keys=None):
90
+ """The `esr` attachment for ecld_result: wallet + entries.
91
+
92
+ Entries default to every key touched this task; `keys` restricts the
93
+ attachment to those keys and force-reads any of them the task did not
94
+ touch (so a pure fetch task can attach state it never mutated).
95
+ """
96
+ reg = StateRegistry()
97
+ if keys:
98
+ for k in keys:
99
+ if k not in _task_ledger:
100
+ reg.get(k) # records into the ledger as a side effect
101
+ wanted = list(keys)
102
+ else:
103
+ wanted = list(_task_ledger.keys())
104
+ entries = []
105
+ for k in wanted:
106
+ entry = _task_ledger.get(k)
107
+ if entry is None:
108
+ continue
109
+ entry = dict(entry)
110
+ if not include_state:
111
+ entry.pop("state", None)
112
+ entries.append(entry)
113
+ return {"wallet": reg.wallet_address, "entries": entries}
114
+
115
+
116
+ def ledger_attestation():
117
+ """The ledger this securelock staged, for the securelock to attest inside
118
+ the signed result envelope AFTER restamp_ledger_caller: the enclave's ESR
119
+ wallet plus the signed authorization records (the same objects written to
120
+ esr.authorizations.json). The trustedzone adjudicates from this copy,
121
+ which the node's storage cannot delete, truncate or edit. Never called
122
+ from payload code. wallet is None when the registry was never configured.
123
+ """
124
+ import esr_wallet
125
+
126
+ wallet = esr_wallet.derive_wallet_address(_identity_priv) if _identity_priv else None
127
+ return {"wallet": wallet, "commits": [dict(e) for e in _esr_evidence]}
128
+
129
+
130
+ # The authenticated task caller: the wallet that placed the DO request, read
131
+ # from the PoX contract by the TRUSTEDZONE and forwarded over its signed
132
+ # handoff (caller.securelock + .sig, verified by securelock before configure).
133
+ # None when the trustedzone did not supply one (older trustedzone) -- anonymity
134
+ # is never a privilege: None cannot claim ownership or touch owned state.
135
+ _task_caller = None
136
+
137
+
138
+ def task_caller():
139
+ """The verified wallet that submitted this task, or None (see above)."""
140
+ return _task_caller
141
+
142
+
143
+ def _norm_addr(addr):
144
+ return addr.lower() if isinstance(addr, str) else None
145
+
146
+
147
+ # LOCAL TESTING ONLY. In the real enclave the NODE applies commitFor on-chain,
148
+ # so this is None and _send_commit merely SIGNS + stages the authorization. The
149
+ # local ESR emulator (esr_local.install) sets a callback that applies the commit
150
+ # to its in-memory registry, so `ecld-test` can run state-using backends with no
151
+ # chain, node, or SGX. Never set in production.
152
+ _local_commit_apply = None
153
+
154
+
155
+ def set_local_commit_apply(fn):
156
+ """Install the local commit-apply hook (esr_local only)."""
157
+ global _local_commit_apply
158
+ _local_commit_apply = fn
159
+
160
+
161
+ def restamp_ledger_caller(trusted_caller):
162
+ """Post-payload: re-assert the trusted caller across the ESR ledger.
163
+
164
+ Called by the SECURELOCK after the payload returns, with the attested
165
+ caller it captured BEFORE the payload ran (held on the securelock instance,
166
+ which the payload's exec globals never receive). We overwrite `callerUsed`
167
+ (and re-fix `enclave` to the real signer) in every ledger entry with that
168
+ trusted value and RE-STAGE the ledger, so a payload that had forged the
169
+ in-enclave caller cannot leave a false `callerUsed` for the trustedzone.
170
+
171
+ Returns True if any entry's callerUsed differed from the trusted caller
172
+ before re-stamping -- i.e. tampering was detected -- so the securelock can
173
+ fail the task locally in addition to the trustedzone catching it.
174
+
175
+ Bounds: this runs in the same process the payload subverted; a determined
176
+ payload could also patch THIS function. It is defence in depth, not the
177
+ sound layer -- the trustedzone (a separate enclave) is the authoritative
178
+ judge, and the caller is ALSO bound into each commit's CID (signed).
179
+ """
180
+ trusted = _norm_addr(trusted_caller)
181
+ tampered = False
182
+ if not _esr_evidence:
183
+ return False
184
+ try:
185
+ signer_addr = StateRegistry()._signer().address
186
+ except Exception:
187
+ signer_addr = None
188
+ for entry in _esr_evidence:
189
+ if not entry.get("owned"):
190
+ continue
191
+ prior = _norm_addr(entry.get("callerUsed"))
192
+ if prior != trusted:
193
+ tampered = True
194
+ entry["callerUsed"] = trusted or ""
195
+ if signer_addr:
196
+ entry["enclave"] = signer_addr
197
+ try:
198
+ ledger = json.dumps(_esr_evidence, separators=(",", ":")).encode("utf-8")
199
+ _swift.put_file_content(
200
+ _bucket, "esr.authorizations.json", "", io.BytesIO(ledger))
201
+ except Exception:
202
+ pass # best-effort restage; the trustedzone still adjudicates
203
+ return tampered
204
+
205
+
206
+ def _owner_op(key, mutator):
207
+ """Owner-only ACL mutation. An unowned key is claimed first (owner =
208
+ caller), so management on fresh keys works without a prior data commit.
209
+ Relays like any state write; no contract involvement beyond commitFor."""
210
+ if _task_caller is None:
211
+ raise StatePermissionError(
212
+ "state management requires an authenticated caller "
213
+ "(the trustedzone supplied none)")
214
+ reg = StateRegistry()
215
+
216
+ def transform(acl, data, version):
217
+ if acl is None:
218
+ acl = _new_acl(_task_caller)
219
+ if _norm_addr(acl.get("owner")) != _task_caller:
220
+ raise StatePermissionError(
221
+ f"caller {_task_caller} is not the owner of state key '{key}' "
222
+ f"(owner: {acl.get('owner')})")
223
+ return mutator(dict(acl)), data
224
+
225
+ acl, _data = reg._commit_transform(key, transform)
226
+ return dict(acl)
227
+
228
+
229
+ def esr_grant(key, address, level="read"):
230
+ """Owner-only: grant `address` read or write access to `key`."""
231
+ if level not in ("read", "write"):
232
+ raise ValueError("level must be 'read' or 'write'")
233
+ addr = _norm_addr(address)
234
+ if not addr:
235
+ raise ValueError("a grantee address is required")
236
+
237
+ def mut(acl):
238
+ members = {a for a in (_norm_addr(x) for x in (acl.get(level) or [])) if a}
239
+ members.add(addr)
240
+ acl[level] = sorted(members)
241
+ return acl
242
+ return _owner_op(key, mut)
243
+
244
+
245
+ def esr_revoke(key, address, level="read"):
246
+ """Owner-only: revoke `address`'s read or write access to `key`."""
247
+ if level not in ("read", "write"):
248
+ raise ValueError("level must be 'read' or 'write'")
249
+ addr = _norm_addr(address)
250
+
251
+ def mut(acl):
252
+ acl[level] = sorted(
253
+ a for a in (_norm_addr(x) for x in (acl.get(level) or []))
254
+ if a and a != addr)
255
+ return acl
256
+ return _owner_op(key, mut)
257
+
258
+
259
+ def esr_set_public_read(key, enabled=True):
260
+ """Owner-only: make `key` readable by any caller (or revert to private)."""
261
+ def mut(acl):
262
+ acl["public_read"] = bool(enabled)
263
+ return acl
264
+ return _owner_op(key, mut)
265
+
266
+
267
+ def esr_transfer(key, new_owner):
268
+ """Owner-only: transfer ownership of `key`. The old owner keeps nothing
269
+ unless separately granted."""
270
+ addr = _norm_addr(new_owner)
271
+ if not addr:
272
+ raise ValueError("a new owner address is required")
273
+
274
+ def mut(acl):
275
+ acl["owner"] = addr
276
+ return acl
277
+ return _owner_op(key, mut)
278
+
279
+
280
+ def esr_nonce(key):
281
+ """The last accepted idempotency nonce for `key` (0 if none). See
282
+ StateRegistry.get_nonce / commit(nonce=...)."""
283
+ return StateRegistry().get_nonce(key)
284
+
285
+
286
+ def esr_owner(key):
287
+ """The owner address of `key`, or None while unowned. Does not expose the
288
+ state or the grant lists."""
289
+ acl, _data, _version, _cid = StateRegistry()._read_container(key)
290
+ return acl.get("owner") if acl else None
291
+
292
+
293
+ def esr_acl(key):
294
+ """Owner-only: the full ACL of `key`."""
295
+ reg = StateRegistry()
296
+ acl, _data, _version, _cid = reg._read_container(key)
297
+ if acl is None:
298
+ return None
299
+ if _task_caller is None or _norm_addr(acl.get("owner")) != _task_caller:
300
+ raise StatePermissionError(
301
+ f"caller {_task_caller or '<anonymous>'} is not the owner of "
302
+ f"state key '{key}'")
303
+ return dict(acl)
304
+
305
+
306
+ def configure(identity_priv, swift_stream_service, bucket, contract_address,
307
+ web3=None, caller=None):
308
+ """Wire the registry to the enclave's identity, storage and chain access.
309
+
310
+ Called by securelock during startup; payload code never calls this.
311
+ `caller` MUST only ever be the trustedzone-attested DO owner -- never a
312
+ value from the client payload or the node's .env.
313
+ """
314
+ global _identity_priv, _swift, _bucket, _contract_address, _web3, _esr_evidence
315
+ global _task_ledger, _task_nonces, _task_relay_nonces, _task_caller
316
+ _identity_priv = identity_priv
317
+ _swift = swift_stream_service
318
+ _bucket = bucket
319
+ _contract_address = contract_address
320
+ _web3 = web3
321
+ _task_ledger = {}
322
+ _task_nonces = {}
323
+ _task_relay_nonces = {}
324
+ _esr_evidence = []
325
+ _task_caller = _norm_addr(caller)
326
+
327
+
328
+ """State container + ACL.
329
+
330
+ Stored blobs are either legacy (the raw state JSON -- "unowned") or an owned
331
+ container:
332
+
333
+ {"_ecld_state": 1, "acl": {...}, "data": <the state>}
334
+
335
+ acl = {"owner": addr, "read": [addr...], "write": [addr...],
336
+ "public_read": bool}. Enforcement happens HERE, inside the enclave,
337
+ against the trustedzone-attested caller:
338
+
339
+ read -- owner, read/write members, or public_read; unowned reads freely
340
+ (legacy semantics).
341
+ write -- owner or write members; an unowned key is writable and is CLAIMED
342
+ (owner = caller) on the first write that carries a caller.
343
+ None-caller (older trustedzone): may read unowned/public state and write
344
+ unowned state (never claiming) -- but can never touch owned state.
345
+ """
346
+
347
+ _CONTAINER_MARK = "_ecld_state"
348
+
349
+
350
+ class StatePermissionError(RuntimeError):
351
+ """Raised when the task caller lacks permission for a state operation."""
352
+
353
+
354
+ # Per-run cap on state commits: bounds the number of relayed transactions the
355
+ # node must track/pay and the size of the adjudicated ledger bound into the
356
+ # result transaction. 256 so the on-chain count fits ONE BYTE (bias-1:
357
+ # 0x00 = 1 commit ... 0xFF = 256; an all-zero root means no commits at all).
358
+ MAX_COMMITS_PER_TASK = 256
359
+
360
+
361
+ class StateLimitError(RuntimeError):
362
+ """Raised when a task tries to make more than MAX_COMMITS_PER_TASK state
363
+ commits. The over-limit commit is NOT applied; earlier commits stand."""
364
+
365
+
366
+ class StateNonceError(RuntimeError):
367
+ """Raised when a commit's idempotency nonce is out of sequence.
368
+
369
+ The dApp supplied `nonce=` on commit() and that nonce is not exactly the
370
+ last accepted nonce for the key + 1 -- i.e. this is a duplicate, stale, or
371
+ skipped-ahead submission. The state was NOT changed."""
372
+
373
+
374
+ def _unwrap(stored):
375
+ """(acl, data) from a stored blob; legacy blobs carry no acl.
376
+
377
+ `committedBy` (the caller that authored the commit) is metadata, not part
378
+ of get()'s returned data -- it is dropped here on read.
379
+ """
380
+ if isinstance(stored, dict) and stored.get(_CONTAINER_MARK) == 1:
381
+ return (stored.get("acl") or None), stored.get("data")
382
+ return None, stored
383
+
384
+
385
+ def _wrap(acl, data, committed_by=None, nonce=None):
386
+ """Stored container. For owned state, bind the authoring caller
387
+ (`committedBy`) INTO the blob, so it lands in the CID -- which is the field
388
+ the securelock signs. That makes the caller cryptographically bound to the
389
+ commit, not merely a re-stampable sidecar. Unowned state keeps the bare
390
+ shape (no ACL, no binding) -- unless a nonce must be carried, which forces
391
+ the container shape.
392
+
393
+ `nonce` is a REPORTING COPY of the registry's stored idempotency nonce as
394
+ of this commit. The on-chain value (getNonce) is always the primary; the
395
+ blob copy exists so the signed CID cryptographically commits to the nonce
396
+ the enclave intended, and reads verify the two match (_read_container)."""
397
+ if acl is None and nonce is None:
398
+ return data
399
+ container = {_CONTAINER_MARK: 1, "acl": acl, "data": data}
400
+ if committed_by:
401
+ container["committedBy"] = committed_by
402
+ if nonce is not None:
403
+ container["nonce"] = int(nonce)
404
+ return container
405
+
406
+
407
+ def _acl_members(acl, field):
408
+ return {a for a in (_norm_addr(x) for x in (acl.get(field) or [])) if a}
409
+
410
+
411
+ def _can_read(acl):
412
+ if acl is None or acl.get("public_read"):
413
+ return True
414
+ c = _task_caller
415
+ if c is None:
416
+ return False
417
+ return (c == _norm_addr(acl.get("owner"))
418
+ or c in _acl_members(acl, "read")
419
+ or c in _acl_members(acl, "write"))
420
+
421
+
422
+ def _can_write(acl):
423
+ if acl is None:
424
+ return True # unowned: writable; claimed when a caller exists
425
+ c = _task_caller
426
+ if c is None:
427
+ return False
428
+ return c == _norm_addr(acl.get("owner")) or c in _acl_members(acl, "write")
429
+
430
+
431
+ def _new_acl(owner):
432
+ return {"owner": owner, "read": [], "write": [], "public_read": False}
433
+
434
+
435
+ def _keccak256(data: bytes) -> bytes:
436
+ try:
437
+ from Crypto.Hash import keccak
438
+
439
+ h = keccak.new(digest_bits=256)
440
+ h.update(data)
441
+ return h.digest()
442
+ except ImportError:
443
+ from eth_hash.auto import keccak as eth_keccak
444
+
445
+ return eth_keccak(data)
446
+
447
+
448
+ def _state_key() -> bytes:
449
+ """AES key for state, derived under its own domain separator.
450
+
451
+ A separate separator from the wallet's means the two keys are provably
452
+ unrelated: compromising one does not yield the other.
453
+ """
454
+ if not _identity_priv:
455
+ raise RuntimeError("StateRegistry is not configured (no enclave identity)")
456
+ material = _identity_priv if isinstance(_identity_priv, bytes) else str(_identity_priv).encode()
457
+ return _keccak256(DOMAIN_SEP_STATE + material)
458
+
459
+
460
+ def looks_like_cid(value) -> bool:
461
+ """True only for values shaped like an IPFS CID.
462
+
463
+ The contract accepts any non-empty string as the pointer, so a buggy writer
464
+ can commit something that is not a CID (the live registry currently holds
465
+ one 0x… digest). That is a defect in the writer, not a supported format:
466
+ such an entry is rejected here rather than fetched, because passing it on
467
+ means an error at best and a retry-loop at worst.
468
+
469
+ CIDv0 is 46 chars starting "Qm"; CIDv1 is base32 starting "b".
470
+ """
471
+ cid = (value or "").strip()
472
+ if not cid or cid.startswith("0x"):
473
+ return False
474
+ if cid.startswith("Qm") and len(cid) == 46:
475
+ return True
476
+ if cid.startswith("b") and len(cid) >= 46 and cid.islower():
477
+ return True
478
+ return False
479
+
480
+
481
+ def cidv1_raw(content: bytes) -> str:
482
+ """CIDv1/raw/sha2-256 for `content`, computed without touching IPFS.
483
+
484
+ Matches `ipfs add --cid-version=1 --raw-leaves`, which is what lets the
485
+ enclave commit a CID it derived itself rather than one a node handed it.
486
+ """
487
+ digest = hashlib.sha256(content).digest()
488
+ raw = bytes([0x01, 0x55, 0x12, 0x20]) + digest
489
+ return "b" + base64.b32encode(raw).decode("ascii").lower().rstrip("=")
490
+
491
+
492
+ # AES-GCM nonce length, pinned explicitly. PyCryptodome defaults to 16 bytes
493
+ # while 12 is the more common convention -- relying on the default would make the
494
+ # blob layout depend on the library version, so encrypt and decrypt must agree
495
+ # on a fixed value here.
496
+ _NONCE_LEN = 12
497
+ _TAG_LEN = 16
498
+
499
+
500
+ def _encrypt(plaintext: bytes) -> bytes:
501
+ """AES-256-GCM under the state key.
502
+
503
+ Layout: nonce(_NONCE_LEN) || tag(_TAG_LEN) || ciphertext
504
+ """
505
+ from Crypto.Cipher import AES
506
+ from Crypto.Random import get_random_bytes
507
+
508
+ nonce = get_random_bytes(_NONCE_LEN)
509
+ cipher = AES.new(_state_key(), AES.MODE_GCM, nonce=nonce)
510
+ ct, tag = cipher.encrypt_and_digest(plaintext)
511
+ return nonce + tag + ct
512
+
513
+
514
+ def _decrypt(blob: bytes) -> bytes:
515
+ from Crypto.Cipher import AES
516
+
517
+ if len(blob) < _NONCE_LEN + _TAG_LEN:
518
+ raise ValueError("state blob is too short to be valid")
519
+ nonce = blob[:_NONCE_LEN]
520
+ tag = blob[_NONCE_LEN:_NONCE_LEN + _TAG_LEN]
521
+ ct = blob[_NONCE_LEN + _TAG_LEN:]
522
+ cipher = AES.new(_state_key(), AES.MODE_GCM, nonce=nonce)
523
+ return cipher.decrypt_and_verify(ct, tag)
524
+
525
+
526
+ def _key_hash(key: str) -> bytes:
527
+ """The bytes32 the contract is keyed by: keccak256(key)."""
528
+ return _keccak256(key.encode("utf-8"))
529
+
530
+
531
+ class StateRegistry:
532
+ """Durable encrypted state for enclave payload code."""
533
+
534
+ def __init__(self):
535
+ if not _contract_address:
536
+ raise RuntimeError(
537
+ "ESR is not enabled for this enclave. Enable it in .config.json "
538
+ "(or ECLD_ESR_ENABLE) and rebuild, so the registry address is "
539
+ "baked into the image."
540
+ )
541
+ self._priv = None
542
+
543
+ # -- identity -------------------------------------------------------
544
+
545
+ @property
546
+ def wallet_address(self) -> str:
547
+ """The enclave's on-chain address — safe to publish and to fund."""
548
+ import esr_wallet
549
+
550
+ return esr_wallet.derive_wallet_address(_identity_priv)
551
+
552
+ @property
553
+ def enclave_address(self) -> str:
554
+ """Alias of wallet_address — the ENCLAVE's own on-chain address (its
555
+ identity, and the namespace all its state keys live under). Preferred
556
+ name in new code; wallet_address remains for compatibility."""
557
+ return self.wallet_address
558
+
559
+ def _signer(self):
560
+ """eth_account signer for the ESR wallet. Never leaves this class."""
561
+ if self._priv is None:
562
+ import esr_wallet
563
+
564
+ self._priv = esr_wallet._derive_wallet_private_key(_identity_priv)
565
+ from eth_account import Account
566
+
567
+ return Account.from_key(self._priv)
568
+
569
+ # -- reads ----------------------------------------------------------
570
+
571
+ def get_version(self, key: str) -> int:
572
+ """Current on-chain version for `key`; 0 when never committed."""
573
+ contract = self._contract()
574
+ return int(contract.functions.getVersion(self.wallet_address, _key_hash(key)).call())
575
+
576
+ def get(self, key: str, default=None):
577
+ """Decrypted state for `key`, or `default` ({} if unset) when absent.
578
+
579
+ Reads the CID from the chain, fetches the object, decrypts it. A CID the
580
+ enclave cannot fetch (nothing pinned it yet, or the node is offline)
581
+ raises rather than silently returning empty state — treating "cannot
582
+ read" as "no state" would let a commit overwrite good data.
583
+ """
584
+ if default is None:
585
+ default = {}
586
+ acl, data, version, cid = self._read_container(key, default)
587
+ if not _can_read(acl):
588
+ raise StatePermissionError(
589
+ f"caller {_task_caller or '<anonymous>'} has no read permission "
590
+ f"on state key '{key}' (owner: {acl.get('owner')})")
591
+ if version:
592
+ _ledger_record(key, version, cid, data)
593
+ else:
594
+ _ledger_record(key, 0, None, data)
595
+ return data
596
+
597
+ def _read_container(self, key: str, default=None):
598
+ """(acl, data, version, cid) for `key`; NO permission check here."""
599
+ if default is None:
600
+ default = {}
601
+ contract = self._contract()
602
+ cid, version, _updated = contract.functions.getState(
603
+ self.wallet_address, _key_hash(key)).call()
604
+ if not version or not cid:
605
+ return None, default, 0, None
606
+ if not looks_like_cid(cid):
607
+ # Fail loudly rather than returning `default`: treating a broken
608
+ # pointer as "no state" would let the next commit overwrite state
609
+ # that may still be recoverable once the writer is fixed.
610
+ raise RuntimeError(
611
+ f"ESR entry for '{key}' holds a pointer that is not a CID "
612
+ f"({cid[:32]}…). The committing code is writing a non-CID value.")
613
+ blob = self._fetch(key, cid)
614
+ stored = json.loads(_decrypt(blob).decode("utf-8"))
615
+ acl, data = _unwrap(stored)
616
+ if isinstance(stored, dict) and stored.get(_CONTAINER_MARK) == 1 and "nonce" in stored:
617
+ # The blob carries a REPORTING COPY of the idempotency nonce; the
618
+ # chain is primary. Both were written by the same commit (the CID
619
+ # the chain points at IS this blob), so they must agree -- a
620
+ # mismatch means the registry entry and the state object have been
621
+ # tampered with or torn apart. Compare against the RAW chain value
622
+ # (not the task memo): the blob we fetched is the chain's cid.
623
+ chain_nonce = int(contract.functions.getNonce(
624
+ self.wallet_address, _key_hash(key)).call())
625
+ blob_nonce = int(stored.get("nonce") or 0)
626
+ if blob_nonce != chain_nonce:
627
+ raise RuntimeError(
628
+ f"ESR nonce mismatch for '{key}': the state object reports "
629
+ f"nonce {blob_nonce} but the registry records {chain_nonce} "
630
+ f"-- refusing to use this state")
631
+ return acl, data, int(version), cid
632
+
633
+ # -- writes ---------------------------------------------------------
634
+
635
+ def commit(self, key: str, mutate, attempts: int = 3, nonce=None):
636
+ """Read-modify-write `key` under optimistic concurrency.
637
+
638
+ `mutate` receives the current state (the DATA, never the ACL
639
+ container) and returns the new state. Write permission is enforced
640
+ against the trustedzone-attested caller; the first write that carries
641
+ a caller CLAIMS an unowned key (owner = caller).
642
+
643
+ `nonce` (optional int): the per-key sequence advances by exactly 1 on
644
+ EVERY commit (1, 2, 3, ... -- no gaps, no reuse; see get_nonce).
645
+
646
+ - Omitted: the SDK takes the next value in sequence automatically.
647
+ The commit always succeeds (subject to permissions/versioning) but
648
+ carries NO exactly-once guarantee -- a resubmitted task simply
649
+ picks a fresh number.
650
+ - Explicit: it must be EXACTLY the last accepted nonce + 1, pinned by
651
+ the CLIENT before submitting (read it with a free eth_call and pass
652
+ +1). Anything else raises StateNonceError (task code 36) and the
653
+ state is NOT changed -- so a resubmitted task cannot apply the same
654
+ commit twice.
655
+
656
+ Returns the new state.
657
+ """
658
+ def transform(acl, data, version):
659
+ if not _can_write(acl):
660
+ raise StatePermissionError(
661
+ f"caller {_task_caller or '<anonymous>'} has no write "
662
+ f"permission on state key '{key}' (owner: {acl.get('owner')})")
663
+ if acl is None and _task_caller:
664
+ acl = _new_acl(_task_caller) # first-writer-owns
665
+ return acl, mutate(data)
666
+
667
+ _acl, new_data = self._commit_transform(key, transform, attempts,
668
+ nonce=nonce)
669
+ return new_data
670
+
671
+ def get_nonce(self, key: str) -> int:
672
+ """The last accepted idempotency nonce for `key` (0 if none was ever
673
+ used). A dApp that wants duplicate suppression reads this and passes
674
+ get_nonce(key) + 1 to commit() -- the contract accepts EXACTLY the
675
+ next value in sequence, nothing else.
676
+
677
+ The nonce is PUBLIC data: the registry records it on-chain next to the
678
+ version, and anyone can read it with a free eth_call (getNonce) -- so
679
+ no read-ACL applies here, and web3 clients see the same value via the
680
+ runner's esrNonce.
681
+
682
+ Reads the chain (authoritative, post-relay), merged with this task's
683
+ own accepted commits so `commit(nonce=N); get_nonce()` returns N even
684
+ before the node's relay lands."""
685
+ contract = self._contract()
686
+ chain = int(contract.functions.getNonce(
687
+ self.wallet_address, _key_hash(key)).call())
688
+ return max(chain, int(_task_nonces.get(key, 0)))
689
+
690
+ def _commit_transform(self, key: str, transform, attempts: int = 3,
691
+ nonce=None):
692
+ """Optimistic-concurrency commit of transform(acl, data, version).
693
+
694
+ `transform` returns (new_acl, new_data); the stored blob is the
695
+ wrapped container (or the bare data while unowned). If another commit
696
+ lands in between, the contract rejects ours (VersionMismatch) and we
697
+ re-read and retry, so concurrent tasks cannot silently lose updates.
698
+ The nonce check re-runs on every retry against the freshly read state,
699
+ so a racing commit that consumed the same nonce fails this one.
700
+ """
701
+ if len(_esr_evidence) >= MAX_COMMITS_PER_TASK:
702
+ raise StateLimitError(
703
+ f"this task already made {len(_esr_evidence)} state commits -- "
704
+ f"the per-run limit is {MAX_COMMITS_PER_TASK}. Batch your "
705
+ f"writes (one commit can update a whole object) or split the "
706
+ f"work across tasks.")
707
+ last_error = None
708
+ for _attempt in range(attempts):
709
+ acl, data, current_version, _cid = self._read_container(key)
710
+ if nonce is not None:
711
+ # Pre-check against the freshest visible nonce (chain merged
712
+ # with this task's own accepted commits) so a duplicate fails
713
+ # in-enclave with task code 36 instead of as a failed relay.
714
+ # The contract re-enforces the same rule on-chain either way.
715
+ final_nonce = int(nonce)
716
+ if final_nonce == 0:
717
+ raise StateNonceError(
718
+ f"nonce=0 is reserved for 'not set' (the registry "
719
+ f"then assigns the next value itself) -- omit the "
720
+ f"nonce on commit('{key}', ...) instead of passing 0. "
721
+ f"Pinned nonces start at 1: use get_nonce() + 1.")
722
+ stored_nonce = self.get_nonce(key)
723
+ if final_nonce != stored_nonce + 1:
724
+ raise StateNonceError(
725
+ f"nonce {final_nonce} is out of sequence for state "
726
+ f"key '{key}' (last accepted: {stored_nonce}, "
727
+ f"expected: {stored_nonce + 1}); commit suppressed, "
728
+ f"state unchanged")
729
+ else:
730
+ # No explicit nonce: the CONTRACT assigns the next value in
731
+ # sequence itself (wire nonce 0 = "omitted"); this predicted
732
+ # value feeds the blob's reporting copy and the task memo.
733
+ # The sequence advances on EVERY commit; only an EXPLICIT
734
+ # nonce carries the exactly-once guarantee (an omitted one is
735
+ # auto-assigned a fresh number, so it cannot deduplicate --
736
+ # by design).
737
+ final_nonce = self.get_nonce(key) + 1
738
+ new_acl, new_data = transform(acl, data, current_version)
739
+ # The blob carries a REPORTING COPY of the nonce the registry will
740
+ # store for this key after this commit. Chain stays primary;
741
+ # reads verify the two match.
742
+ # Bind the authoring caller into the blob (hence the CID/signature)
743
+ # for owned state.
744
+ stored = _wrap(new_acl, new_data,
745
+ committed_by=_task_caller if new_acl else None,
746
+ nonce=final_nonce)
747
+
748
+ blob = _encrypt(json.dumps(stored, separators=(",", ":")).encode("utf-8"))
749
+ # Compute the CID from OUR bytes -- never accept one from the node.
750
+ cid = cidv1_raw(blob)
751
+
752
+ # Hand the blob and the CID to the node for pinning. It verifies the
753
+ # CID matches; it does not get to choose it. Fire-and-forget: the
754
+ # commit below does not wait for pinning to finish.
755
+ self._publish(key, blob, cid)
756
+
757
+ try:
758
+ owner_addr = _norm_addr(new_acl.get("owner")) if new_acl else None
759
+ # Wire value: the pinned nonce, or 0 = "omitted" -- the
760
+ # contract then assigns the next value atomically on-chain.
761
+ self._send_commit(_key_hash(key), cid, current_version,
762
+ owner_addr=owner_addr,
763
+ nonce=(int(nonce) if nonce is not None else 0))
764
+ # Record the POST-commit values: this is what the chain will
765
+ # show once the node's relay lands (version increments by one).
766
+ # The ledger records the DATA -- the ACL never leaves in results.
767
+ _ledger_record(key, current_version + 1, cid, new_data)
768
+ _task_nonces[key] = final_nonce
769
+ return new_acl, new_data
770
+ except Exception as e:
771
+ # A version race means someone else committed first: re-read and
772
+ # rebuild on top of their state rather than clobbering it.
773
+ if "VersionMismatch" in str(e) or "version" in str(e).lower():
774
+ last_error = e
775
+ continue
776
+ raise
777
+ raise RuntimeError(f"ESR commit failed after {attempts} attempts: {last_error}")
778
+
779
+ # -- plumbing -------------------------------------------------------
780
+
781
+ def _contract(self):
782
+ if _web3 is None:
783
+ raise RuntimeError("StateRegistry has no web3 provider")
784
+ abi_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "esr.abi")
785
+ with open(abi_path) as f:
786
+ abi = f.read()
787
+ return _web3.eth.contract(
788
+ address=_web3.to_checksum_address(_contract_address), abi=abi)
789
+
790
+ def _publish(self, key: str, blob: bytes, cid: str):
791
+ """Drop the blob + our CID in the bucket for the node to pin.
792
+
793
+ put_file_content sizes its upload via object_data.getbuffer(), so it
794
+ needs a BufferedIO object -- raw bytes/str raise AttributeError and
795
+ every commit() died here before anything reached the node.
796
+ """
797
+ _swift.put_file_content(_bucket, f"state.{key}.enc", "", io.BytesIO(blob))
798
+ _swift.put_file_content(_bucket, f"state.{key}.cid", "", io.BytesIO(cid.encode("utf-8")))
799
+
800
+ def _fetch(self, key: str, cid: str) -> bytes:
801
+ """Read a state object back, verifying it against the on-chain CID.
802
+
803
+ The enclave cannot reach IPFS (the Kubo API binds loopback on the host),
804
+ so the node stages the object in the bucket. That makes the node the
805
+ delivery path -- but not a trusted one: the content is checked against
806
+ the CID the chain records, so a substituted or corrupted blob is
807
+ rejected rather than decrypted and returned as state.
808
+ """
809
+ candidates = (f"state.{key}.enc", f"state.{cid}.enc")
810
+ for name in candidates:
811
+ ok, data = _swift.get_file_content_bytes(_bucket, name)
812
+ if ok and data:
813
+ if cidv1_raw(data) != cid:
814
+ raise RuntimeError(
815
+ f"State object {name} does not match the committed CID "
816
+ f"{cid} -- refusing to use it")
817
+ return data
818
+ raise RuntimeError(f"Could not read state object for '{key}' ({cid})")
819
+
820
+ def _send_commit(self, key_hash: bytes, cid: str, expected_version: int,
821
+ owner_addr: str = None, nonce: int = 0):
822
+ """Authorize a commit for the NODE to relay and pay (commitFor).
823
+
824
+ The enclave never pays gas and does NO gas math: it just SIGNS the
825
+ commit (bound to enclave, key, cid, version, relay nonce, chain,
826
+ contract) and stages the signed authorization for the node. The NODE
827
+ prices the gas (it is paying, and knows live fees) and enforces the
828
+ per-order budget; the TRUSTEDZONE independently re-prices and adjudicates
829
+ as the attested check on the node. Cost is not the securelock's concern
830
+ and nothing it could write about cost would be trusted anyway.
831
+
832
+ Two artifacts are staged, both carrying only signature-bound fields:
833
+ - esr.commit.<key16>.<relayNonce>.json -- the single authorization
834
+ for the node to relay; per (enclave, key) the relay nonces are
835
+ strictly sequential, and different keys relay independently;
836
+ - esr.authorizations.json -- the append-only ledger of EVERY commit
837
+ this order, which the trustedzone adjudicates over so no commit can
838
+ escape the cumulative gas accounting by never being seen.
839
+ """
840
+ contract = self._contract()
841
+ acct = self._signer()
842
+ enclave = acct.address
843
+
844
+ # Relay nonce is PER (enclave, key): same-key commits are strictly
845
+ # serialized network-wide, different keys relay in parallel. Merge the
846
+ # chain value with this task's own staged commits so a second commit
847
+ # to the same key in ONE task signs the next number even before (or
848
+ # while) the node relays the first.
849
+ kh_hex = key_hash.hex()
850
+ chain_rn = int(contract.functions.relayNonce(enclave, key_hash).call())
851
+ relay_nonce = max(chain_rn, int(_task_relay_nonces.get(kh_hex, 0)))
852
+ # The idempotency nonce is SIGNATURE-BOUND (part of the digest): the
853
+ # relaying node can only broadcast, or not, exactly what was signed --
854
+ # it cannot alter the nonce the registry will record. 0 = no guard,
855
+ # preserve the stored value. NOTE: the recorded nonce is PUBLIC on-chain.
856
+ digest = contract.functions.commitDigest(
857
+ enclave, key_hash, cid, expected_version, relay_nonce,
858
+ int(nonce or 0)).call()
859
+
860
+ from eth_account.messages import encode_defunct
861
+ signature = acct.sign_message(encode_defunct(digest)).signature
862
+
863
+ auth = {
864
+ "enclave": enclave,
865
+ "keyHash": "0x" + key_hash.hex(),
866
+ "cid": cid,
867
+ "expectedVersion": expected_version,
868
+ "relayNonce": relay_nonce,
869
+ "nonce": int(nonce or 0),
870
+ "signature": "0x" + signature.hex(),
871
+ # IMPERSONATION GUARD (see the trustedzone's re-adjudication):
872
+ # `callerUsed` is the caller this commit ran under; `owned` marks a
873
+ # commit to an owned key. The trustedzone requires callerUsed to
874
+ # equal the DO owner it reads from the PoX contract, so a payload
875
+ # that forged the in-enclave caller to IMPERSONATE another user is
876
+ # rejected outside the payload's process. It does NOT (and cannot,
877
+ # without reading the encrypted ACL) verify that this caller was a
878
+ # permitted writer of the key -- that stays an in-enclave check on
879
+ # honest payload code. Unowned commits carry no constraint.
880
+ "callerUsed": _task_caller or "",
881
+ "owned": bool(owner_addr),
882
+ }
883
+
884
+ # Append to the order-wide ledger (the trustedzone's adjudication input).
885
+ _esr_evidence.append(auth)
886
+ ledger = json.dumps(_esr_evidence, separators=(",", ":")).encode("utf-8")
887
+ _swift.put_file_content(
888
+ _bucket, "esr.authorizations.json", "", io.BytesIO(ledger))
889
+
890
+ # Stage the individually-relayable authorization for the node. The
891
+ # filename carries the key prefix so commits to different keys within
892
+ # one task never collide, and the per-key relay order stays explicit.
893
+ blob = json.dumps(auth, separators=(",", ":")).encode("utf-8")
894
+ _swift.put_file_content(
895
+ _bucket, f"esr.commit.{kh_hex[:16]}.{relay_nonce}.json", "",
896
+ io.BytesIO(blob))
897
+ _task_relay_nonces[kh_hex] = relay_nonce + 1
898
+
899
+ # LOCAL TESTING: with no node to relay commitFor, apply the commit to the
900
+ # local registry now so get()/get_version() reflect it. No-op in the real
901
+ # enclave (hook is None) -- there the node applies it on-chain.
902
+ if _local_commit_apply is not None:
903
+ _local_commit_apply(enclave, key_hash, cid, expected_version,
904
+ int(nonce or 0))
905
+
906
+ return {"relayed": True, "relayNonce": relay_nonce}