RFTSystems commited on
Commit
4bd7b8f
·
verified ·
1 Parent(s): 5bbf1d0

Create replayproof_receipts.py

Browse files
Files changed (1) hide show
  1. replayproof_receipts.py +423 -0
replayproof_receipts.py ADDED
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import json
5
+ import base64
6
+ import zipfile
7
+ import hashlib
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from typing import Dict, Any, List, Optional, Tuple
12
+
13
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
14
+ Ed25519PrivateKey,
15
+ Ed25519PublicKey,
16
+ )
17
+ from cryptography.hazmat.primitives import serialization
18
+
19
+ from replayproof_sim import (
20
+ SimConfig,
21
+ SimState,
22
+ reset_sim,
23
+ step_sim,
24
+ render_world_image,
25
+ render_pov_image,
26
+ observation_sha256,
27
+ )
28
+ from replayproof_media import export_gif, sha256_file
29
+
30
+ EVENT_SPEC = "replayproof-event-v0"
31
+ ROOT_SPEC = "replayproof-root-v0"
32
+
33
+
34
+ def now_utc_iso() -> str:
35
+ return datetime.now(timezone.utc).isoformat()
36
+
37
+
38
+ def canon(obj) -> bytes:
39
+ return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
40
+
41
+
42
+ def sha256_hex(b: bytes) -> str:
43
+ return hashlib.sha256(b).hexdigest()
44
+
45
+
46
+ def pubkey_id(pk_bytes: bytes) -> str:
47
+ return sha256_hex(pk_bytes)[:16]
48
+
49
+
50
+ def b64e(b: bytes) -> str:
51
+ return base64.b64encode(b).decode("ascii")
52
+
53
+
54
+ def b64d(s: str) -> bytes:
55
+ return base64.b64decode(s.encode("ascii"))
56
+
57
+
58
+ @dataclass
59
+ class Recorder:
60
+ session_id: str
61
+ cfg: Dict[str, Any]
62
+ seed: int
63
+ sk: Ed25519PrivateKey
64
+ pk: Ed25519PublicKey
65
+ pk_bytes: bytes
66
+ pk_id: str
67
+ events: List[Dict[str, Any]]
68
+
69
+ @staticmethod
70
+ def new_session(sim_spec: str, cfg: Dict[str, Any], seed: int) -> "Recorder":
71
+ sk = Ed25519PrivateKey.generate()
72
+ pk = sk.public_key()
73
+
74
+ pk_bytes = pk.public_bytes(
75
+ encoding=serialization.Encoding.Raw,
76
+ format=serialization.PublicFormat.Raw,
77
+ )
78
+ pid = pubkey_id(pk_bytes)
79
+
80
+ return Recorder(
81
+ session_id=os.urandom(16).hex(),
82
+ cfg={"sim_spec": sim_spec, "cfg": cfg},
83
+ seed=int(seed),
84
+ sk=sk,
85
+ pk=pk,
86
+ pk_bytes=pk_bytes,
87
+ pk_id=pid,
88
+ events=[],
89
+ )
90
+
91
+ def _sign(self, event_sha256_hex: str) -> str:
92
+ sig = self.sk.sign(bytes.fromhex(event_sha256_hex))
93
+ return b64e(sig)
94
+
95
+ def record_event(self, state: SimState, action: str) -> Dict[str, Any]:
96
+ prev = self.events[-1]["event_sha256"] if self.events else None
97
+
98
+ body = {
99
+ "spec": EVENT_SPEC,
100
+ "session_id": self.session_id,
101
+ "ts": now_utc_iso(),
102
+ "step": int(state.step),
103
+ "action": str(action),
104
+ "seed": int(self.seed),
105
+ "cfg_sha256": sha256_hex(canon(self.cfg)),
106
+ "obs_sha256": observation_sha256(state),
107
+ "state_sha256": state.last_state_sha256,
108
+ "prev_event_sha256": prev,
109
+ "pubkey_id": self.pk_id,
110
+ }
111
+
112
+ eh = sha256_hex(canon(body))
113
+ event = dict(body)
114
+ event["event_sha256"] = eh
115
+ event["sig"] = self._sign(eh)
116
+
117
+ self.events.append(event)
118
+ return event
119
+
120
+ def root_manifest(self, final_state: SimState, media_sha256: Optional[str] = None) -> Dict[str, Any]:
121
+ first = self.events[0]["event_sha256"] if self.events else None
122
+ last = self.events[-1]["event_sha256"] if self.events else None
123
+
124
+ root_body = {
125
+ "spec": ROOT_SPEC,
126
+ "session_id": self.session_id,
127
+ "created_utc": now_utc_iso(),
128
+ "seed": int(self.seed),
129
+ "cfg": self.cfg,
130
+ "pubkey": b64e(self.pk_bytes),
131
+ "pubkey_id": self.pk_id,
132
+ "events_count": len(self.events),
133
+ "first_event_sha256": first,
134
+ "final_event_sha256": last,
135
+ "final_state_sha256": final_state.last_state_sha256,
136
+ "media_sha256": media_sha256,
137
+ }
138
+
139
+ root_sha = sha256_hex(canon(root_body))
140
+ root_sig = b64e(self.sk.sign(bytes.fromhex(root_sha)))
141
+
142
+ root = dict(root_body)
143
+ root["root_sha256"] = root_sha
144
+ root["root_sig"] = root_sig
145
+ return root
146
+
147
+
148
+ def _verify_event_signature(event: Dict[str, Any], pk: Ed25519PublicKey) -> Tuple[bool, str]:
149
+ try:
150
+ sig = b64d(event["sig"])
151
+ eh = event["event_sha256"]
152
+ pk.verify(sig, bytes.fromhex(eh))
153
+ return True, "ok"
154
+ except Exception as e:
155
+ return False, f"signature_failed: {type(e).__name__}: {e}"
156
+
157
+
158
+ def _recompute_event_hash(event: Dict[str, Any]) -> str:
159
+ body = dict(event)
160
+ body.pop("sig", None)
161
+ body.pop("event_sha256", None)
162
+ return sha256_hex(canon(body))
163
+
164
+
165
+ def build_receipt_bundle_zip(
166
+ zip_path: str,
167
+ recorder: Recorder,
168
+ cfg: SimConfig,
169
+ final_state: SimState,
170
+ include_gif: bool = True,
171
+ watermark: bool = True,
172
+ ) -> str:
173
+ os.makedirs(os.path.dirname(zip_path), exist_ok=True)
174
+
175
+ gif_path = None
176
+ media_sha = None
177
+
178
+ if include_gif:
179
+ # Replay frames deterministically for a POV clip
180
+ frames = []
181
+ st = reset_sim(cfg, recorder.seed)
182
+ frames.append(render_pov_image(st))
183
+
184
+ for ev in recorder.events:
185
+ if ev["action"] == "RESET":
186
+ continue
187
+ st, _ = step_sim(cfg, st) # deterministic policy matches recorded run
188
+ frames.append(render_pov_image(st))
189
+ if st.done:
190
+ break
191
+
192
+ wm = None
193
+ if watermark:
194
+ wm = f"final:{(final_state.last_state_sha256 or '')[:12]}"
195
+
196
+ gif_path = os.path.join(os.path.dirname(zip_path), f"run_{recorder.session_id}.gif")
197
+ export_gif(frames, gif_path, fps=10, watermark=wm)
198
+ media_sha = sha256_file(gif_path)
199
+
200
+ root = recorder.root_manifest(final_state=final_state, media_sha256=media_sha)
201
+
202
+ events_jsonl = "\n".join(json.dumps(e, ensure_ascii=False) for e in recorder.events) + ("\n" if recorder.events else "")
203
+ root_json = json.dumps(root, ensure_ascii=False, indent=2)
204
+
205
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as z:
206
+ z.writestr("events.jsonl", events_jsonl)
207
+ z.writestr("root.json", root_json)
208
+ if gif_path and os.path.exists(gif_path):
209
+ z.write(gif_path, arcname=os.path.basename(gif_path))
210
+
211
+ return zip_path
212
+
213
+
214
+ def _load_bundle(zip_path: str) -> Dict[str, Any]:
215
+ with zipfile.ZipFile(zip_path, "r") as z:
216
+ names = set(z.namelist())
217
+ if "root.json" not in names or "events.jsonl" not in names:
218
+ raise ValueError("bundle_missing_root_or_events")
219
+
220
+ root = json.loads(z.read("root.json").decode("utf-8"))
221
+ events_lines = z.read("events.jsonl").decode("utf-8").splitlines()
222
+ events = [json.loads(line) for line in events_lines if line.strip()]
223
+
224
+ media_name = None
225
+ for n in names:
226
+ if n.lower().endswith(".gif") or n.lower().endswith(".mp4"):
227
+ media_name = n
228
+ break
229
+ media_bytes = z.read(media_name) if media_name else None
230
+
231
+ return {"root": root, "events": events, "media_name": media_name, "media_bytes": media_bytes}
232
+
233
+
234
+ def verify_receipt_bundle_zip(zip_path: str) -> Dict[str, Any]:
235
+ report: Dict[str, Any] = {
236
+ "ok": False,
237
+ "zip": os.path.basename(zip_path),
238
+ "checks": {},
239
+ "errors": [],
240
+ }
241
+
242
+ try:
243
+ bundle = _load_bundle(zip_path)
244
+ root = bundle["root"]
245
+ events = bundle["events"]
246
+ media_name = bundle["media_name"]
247
+ media_bytes = bundle["media_bytes"]
248
+
249
+ report["checks"]["has_root_and_events"] = True
250
+
251
+ # Root signature verification
252
+ pk_bytes = b64d(root["pubkey"])
253
+ pk = Ed25519PublicKey.from_public_bytes(pk_bytes)
254
+
255
+ root_body = dict(root)
256
+ root_sig = root_body.pop("root_sig", None)
257
+ root_sha = root_body.pop("root_sha256", None)
258
+
259
+ recomputed_root_sha = sha256_hex(canon(root_body))
260
+ report["checks"]["root_sha_match"] = (root_sha == recomputed_root_sha)
261
+ if root_sha != recomputed_root_sha:
262
+ report["errors"].append("root_hash_mismatch")
263
+
264
+ try:
265
+ pk.verify(b64d(root_sig), bytes.fromhex(root_sha))
266
+ report["checks"]["root_sig_valid"] = True
267
+ except Exception as e:
268
+ report["checks"]["root_sig_valid"] = False
269
+ report["errors"].append(f"root_sig_invalid: {type(e).__name__}: {e}")
270
+
271
+ # Event chain + signatures + hash recomputation
272
+ prev = None
273
+ chain_ok = True
274
+ sig_ok = True
275
+ hash_ok = True
276
+
277
+ for ev in events:
278
+ if ev.get("prev_event_sha256") != prev:
279
+ chain_ok = False
280
+
281
+ reh = _recompute_event_hash(ev)
282
+ if reh != ev.get("event_sha256"):
283
+ hash_ok = False
284
+
285
+ ok, _ = _verify_event_signature(ev, pk)
286
+ if not ok:
287
+ sig_ok = False
288
+
289
+ prev = ev.get("event_sha256")
290
+
291
+ report["checks"]["event_chain_ok"] = chain_ok
292
+ report["checks"]["event_hashes_ok"] = hash_ok
293
+ report["checks"]["event_sigs_ok"] = sig_ok
294
+
295
+ if not chain_ok:
296
+ report["errors"].append("event_chain_broken")
297
+ if not hash_ok:
298
+ report["errors"].append("event_hash_mismatch")
299
+ if not sig_ok:
300
+ report["errors"].append("event_signature_invalid")
301
+
302
+ # Optional media hash
303
+ if media_name and media_bytes is not None:
304
+ media_sha = hashlib.sha256(media_bytes).hexdigest()
305
+ expected = root.get("media_sha256")
306
+ report["checks"]["media_present"] = True
307
+ report["checks"]["media_sha_match"] = (expected == media_sha)
308
+ if expected and expected != media_sha:
309
+ report["errors"].append("media_hash_mismatch")
310
+ else:
311
+ report["checks"]["media_present"] = False
312
+
313
+ # Replay-verify state hashes (strongest integrity check)
314
+ cfg_dict = root["cfg"]["cfg"]
315
+ cfg = SimConfig(
316
+ size=int(cfg_dict["size"]),
317
+ walls_pct=float(cfg_dict["walls_pct"]),
318
+ coins=int(cfg_dict["coins"]),
319
+ hazards=int(cfg_dict["hazards"]),
320
+ pov_radius=int(cfg_dict["pov_radius"]),
321
+ max_steps=int(cfg_dict.get("max_steps", 2000)),
322
+ )
323
+ seed = int(root["seed"])
324
+
325
+ st = reset_sim(cfg, seed)
326
+
327
+ mismatch_at = None
328
+ for ev in events:
329
+ if ev["action"] == "RESET":
330
+ if ev["state_sha256"] != st.last_state_sha256:
331
+ mismatch_at = ("RESET", ev["step"])
332
+ break
333
+ continue
334
+
335
+ st, _ = step_sim(cfg, st)
336
+ if ev["state_sha256"] != st.last_state_sha256:
337
+ mismatch_at = (ev["action"], ev["step"])
338
+ break
339
+
340
+ if st.done:
341
+ break
342
+
343
+ report["checks"]["replay_state_hashes_ok"] = (mismatch_at is None)
344
+ if mismatch_at is not None:
345
+ report["errors"].append(f"replay_state_hash_mismatch_at: action={mismatch_at[0]} step={mismatch_at[1]}")
346
+
347
+ last_event = events[-1]["event_sha256"] if events else None
348
+ report["checks"]["final_event_matches_root"] = (root.get("final_event_sha256") == last_event)
349
+ if root.get("final_event_sha256") != last_event:
350
+ report["errors"].append("root_final_event_mismatch")
351
+
352
+ report["ok"] = (len(report["errors"]) == 0)
353
+ return report
354
+
355
+ except Exception as e:
356
+ report["errors"].append(f"exception: {type(e).__name__}: {e}")
357
+ return report
358
+
359
+
360
+ def replay_from_receipt_bundle_zip(zip_path: str, export_gif: bool = True, watermark: bool = True) -> Dict[str, Any]:
361
+ out: Dict[str, Any] = {
362
+ "ok": False,
363
+ "message": "",
364
+ "world_img": None,
365
+ "pov_img": None,
366
+ "gif_path": None,
367
+ "final_hash": None,
368
+ "expected_hash": None,
369
+ }
370
+
371
+ ver = verify_receipt_bundle_zip(zip_path)
372
+ bundle = _load_bundle(zip_path)
373
+ root = bundle["root"]
374
+ events = bundle["events"]
375
+
376
+ cfg_dict = root["cfg"]["cfg"]
377
+ cfg = SimConfig(
378
+ size=int(cfg_dict["size"]),
379
+ walls_pct=float(cfg_dict["walls_pct"]),
380
+ coins=int(cfg_dict["coins"]),
381
+ hazards=int(cfg_dict["hazards"]),
382
+ pov_radius=int(cfg_dict["pov_radius"]),
383
+ max_steps=int(cfg_dict.get("max_steps", 2000)),
384
+ )
385
+ seed = int(root["seed"])
386
+
387
+ st = reset_sim(cfg, seed)
388
+ frames = [render_pov_image(st)]
389
+
390
+ for ev in events:
391
+ if ev["action"] == "RESET":
392
+ continue
393
+ st, _ = step_sim(cfg, st)
394
+ frames.append(render_pov_image(st))
395
+ if st.done:
396
+ break
397
+
398
+ out["world_img"] = render_world_image(st)
399
+ out["pov_img"] = render_pov_image(st)
400
+ out["final_hash"] = st.last_state_sha256
401
+ out["expected_hash"] = root.get("final_state_sha256")
402
+
403
+ if export_gif:
404
+ tmp_dir = tempfile.mkdtemp(prefix="replayproof_replay_")
405
+ gif_path = os.path.join(tmp_dir, f"replay_{root['session_id']}.gif")
406
+ wm = None
407
+ if watermark:
408
+ wm = f"final:{(st.last_state_sha256 or '')[:12]}"
409
+ export_gif(frames, gif_path, fps=10, watermark=wm)
410
+ out["gif_path"] = gif_path
411
+
412
+ if ver["ok"] and out["final_hash"] == out["expected_hash"]:
413
+ out["ok"] = True
414
+ out["message"] = f"✅ Replay OK. final_state_sha256 matches. ({(out['final_hash'] or '')[:12]})"
415
+ else:
416
+ out["ok"] = False
417
+ out["message"] = (
418
+ f"❌ Replay mismatch or verification failed.\n"
419
+ f"verify_ok={ver['ok']} replay_final={(out['final_hash'] or '')[:12]} expected={(out['expected_hash'] or '')[:12]}\n"
420
+ f"errors={ver.get('errors')}"
421
+ )
422
+
423
+ return out