@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.
- package/README.md +44 -6
- package/cas/config.js +61 -3
- package/info.js +333 -324
- package/init.js +20 -4
- package/nodenithy/build/securelock/Dockerfile.tmpl +34 -32
- package/nodenithy/build/securelock/scripts/binary-fs-build.sh +7 -6
- package/nodenithy/build/securelock/src/securelock.js.tmpl +5 -2
- package/nodenithy/build.mjs +83 -71
- package/nodenithy/run/docker-compose-final.yml.tmpl +36 -31
- package/nodenithy/run/docker-compose.yml.tmpl +34 -45
- package/nodenithy/run/image_registry.js +396 -380
- package/nodenithy/run.js +42 -28
- package/package.json +5 -3
- package/publish.js +4 -2
- package/pynithy/build/securelock/Dockerfile.base.tpl +13 -0
- package/pynithy/build/securelock/Dockerfile.tpl +131 -0
- package/pynithy/build/securelock/scripts/binary-fs-build.sh +25 -34
- package/pynithy/build/securelock/scripts/pyfreeze.sh +32 -0
- package/pynithy/build/securelock/src/{pox.abi → abi/etnyImplementationV2.abi} +1489 -1489
- package/pynithy/build/securelock/src/abi/image_registry.abi +1 -0
- package/pynithy/build/securelock/src/ecld_state.py +906 -0
- package/pynithy/build/securelock/src/esr.abi +1 -0
- package/pynithy/build/securelock/src/esr_local.py +267 -0
- package/pynithy/build/securelock/src/esr_wallet.py +150 -0
- package/pynithy/build/securelock/src/etny_crypto.py +208 -167
- package/pynithy/build/securelock/src/etny_exec.py +385 -79
- package/pynithy/build/securelock/src/etny_exec_flask.py +535 -0
- package/pynithy/build/securelock/src/etny_exec_serv.py +353 -0
- package/pynithy/build/securelock/src/etny_session_exec.py +245 -0
- package/pynithy/build/securelock/src/get_sgx_report.so +0 -0
- package/pynithy/build/securelock/src/image_registry.abi +1 -1
- package/pynithy/build/securelock/src/key_generation.py +17 -17
- package/pynithy/build/securelock/src/local_api.py +189 -0
- package/pynithy/build/securelock/src/models.py +200 -176
- package/pynithy/build/securelock/src/securelock.py.tmpl +1037 -727
- package/pynithy/build/securelock/src/swift_stream_service.py +206 -186
- package/pynithy/build.mjs +142 -92
- package/pynithy/run/docker-compose-final.yml.tmpl +58 -51
- package/pynithy/run/docker-compose.yml.tmpl +62 -48
- package/pynithy/run/etny-securelock-test.yaml.tpl +4 -1
- package/pynithy/run/image_registry.js +396 -380
- package/pynithy/run.js +44 -28
- package/pynithy/src/serverless/Dockerfile.serverless +8 -0
- package/pynithy/src/serverless/requirements.txt +6 -0
- package/run.js +69 -28
- package/nodenithy/build/las/Dockerfile +0 -5
- package/pynithy/build/docker-compose.yml +0 -70
- package/pynithy/build/las/Dockerfile +0 -11
- package/pynithy/build/las/src/etny_entrypoint.sh +0 -6
- package/pynithy/build/las/src/las_entrypoint.sh +0 -26
- package/pynithy/build/scripts/binary-fs-build.sh +0 -28
- 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}
|