diff --git a/gravityweave/__init__.py b/gravityweave/__init__.py index dfbab78..2d7ea82 100644 --- a/gravityweave/__init__.py +++ b/gravityweave/__init__.py @@ -16,6 +16,8 @@ from .witness import generate_feasibility_witness from .safety_stub import extract_safety_stub from .mission import MissionTask, ContactWindow, MissionNode, MissionSimulator from .pcp import create_entry, build_chain, inclusion_proof, head_hash, verify_inclusion +from .mfr_v2 import MutualFeasibilityReceiptV2 +from .ticket import PassWindowTicket __all__ = [ "Registry", @@ -38,6 +40,8 @@ __all__ = [ "inclusion_proof", "head_hash", "verify_inclusion", + "MutualFeasibilityReceiptV2", + "PassWindowTicket", "MissionTask", "ContactWindow", "MissionNode", diff --git a/gravityweave/mfr_v2.py b/gravityweave/mfr_v2.py new file mode 100644 index 0000000..d1098fa --- /dev/null +++ b/gravityweave/mfr_v2.py @@ -0,0 +1,119 @@ +"""Mutual Feasibility Receipt v2 (MFR-v2) + +Compact, byte-stable receipt suitable for DTN-era custody handoffs. This +implementation focuses on a small JSON representation with deterministic +serialization and an HMAC-based signature for tests/POC. Fields are chosen to +keep serialized size <= 512 bytes in typical use. + +Fields (compact): +- receipt_id: short random id +- proposal_hash: short hex digest of proposal +- invariant_id_set: list[int] (small ints) +- decision: "ok"/"partial"/"deny" +- uncertainty_pct: coarse uncertainty percent (0..100) +- pass_ticket_hash: optional hex pointer to pre-signed ticket +- execution_hash_skim: optional short hex prefix (e.g., blake2b prefix) +- round_nonce, ts, signer, sig +""" +from dataclasses import dataclass +from typing import List, Optional +import time +import uuid +import json +import hmac +import hashlib + + +@dataclass +class MutualFeasibilityReceiptV2: + receipt_id: str + proposal_hash: str + invariant_id_set: List[int] + decision: str + uncertainty_pct: int + round_nonce: int + ts: float + pass_ticket_hash: str = "" + execution_hash_skim: str = "" + signer: str = "" + sig: str = "" + + @classmethod + def create(cls, proposal_hash: str, invariant_id_set: List[int], decision: str, + uncertainty_pct: int = 0, round_nonce: int = 0, + pass_ticket_hash: Optional[str] = None, + execution_hash_skim: Optional[str] = None, + signer: str = "", key: Optional[bytes] = None) -> "MutualFeasibilityReceiptV2": + rid = uuid.uuid4().hex[:12] + ts = time.time() + r = cls(receipt_id=rid, proposal_hash=proposal_hash, + invariant_id_set=list(invariant_id_set), decision=decision, + uncertainty_pct=int(uncertainty_pct), round_nonce=int(round_nonce), ts=ts, + pass_ticket_hash=pass_ticket_hash or "", + execution_hash_skim=execution_hash_skim or "", + signer=signer, sig="") + if key: + r.sig = r._sign(key) + return r + + def _canonical_payload(self) -> str: + # deterministic and compact JSON over authoritative fields (exclude sig) + payload = { + "receipt_id": self.receipt_id, + "proposal_hash": self.proposal_hash, + "invariant_id_set": self.invariant_id_set, + "decision": self.decision, + "uncertainty_pct": int(self.uncertainty_pct), + "round_nonce": int(self.round_nonce), + "ts": int(self.ts), + "pass_ticket_hash": self.pass_ticket_hash, + "execution_hash_skim": self.execution_hash_skim, + "signer": self.signer, + } + return json.dumps(payload, sort_keys=True, separators=(",", ":")) + + def _sign(self, key: bytes) -> str: + h = hmac.new(key, self._canonical_payload().encode("utf-8"), hashlib.blake2b) + # use hex digest truncated to 64 chars to keep sizes small + return h.hexdigest()[:64] + + def verify(self, key: bytes) -> bool: + if not self.sig: + return False + expected = self._sign(key) + return hmac.compare_digest(expected, self.sig) + + def serialize(self) -> bytes: + obj = { + "receipt_id": self.receipt_id, + "proposal_hash": self.proposal_hash, + "invariant_id_set": self.invariant_id_set, + "decision": self.decision, + "uncertainty_pct": self.uncertainty_pct, + "round_nonce": self.round_nonce, + "ts": self.ts, + "pass_ticket_hash": self.pass_ticket_hash, + "execution_hash_skim": self.execution_hash_skim, + "signer": self.signer, + "sig": self.sig, + } + return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8") + + @classmethod + def deserialize(cls, b: bytes) -> "MutualFeasibilityReceiptV2": + obj = json.loads(b.decode("utf-8")) + return cls(receipt_id=obj.get("receipt_id", ""), + proposal_hash=obj.get("proposal_hash", ""), + invariant_id_set=[int(x) for x in obj.get("invariant_id_set", [])], + decision=obj.get("decision", "deny"), + uncertainty_pct=int(obj.get("uncertainty_pct", 0)), + round_nonce=int(obj.get("round_nonce", 0)), + ts=float(obj.get("ts", 0.0)), + pass_ticket_hash=obj.get("pass_ticket_hash", ""), + execution_hash_skim=obj.get("execution_hash_skim", ""), + signer=obj.get("signer", ""), + sig=obj.get("sig", "")) + + @property + def size_bytes(self) -> int: + return len(self.serialize()) diff --git a/gravityweave/ticket.py b/gravityweave/ticket.py new file mode 100644 index 0000000..045d35d --- /dev/null +++ b/gravityweave/ticket.py @@ -0,0 +1,91 @@ +"""Pass-Window Ticket signer/verifier. + +Compact ticket structure intended for pre-signed pass-window triggers. +Uses HMAC (blake2b) signatures for tests/POC. The ticket hash is the sha256 +of the canonical serialized payload (excluding signature), which can be used as +an external pointer in MFR-v2.pass_ticket_hash. +""" +from dataclasses import dataclass +from typing import Optional +import json +import time +import uuid +import hashlib +import hmac + + +@dataclass +class PassWindowTicket: + window_start: int + window_end: int + comm_budget_bytes: int + resource_budget_digest: str + trigger_policy_id: str + nonce: str + signer: str = "" + sig: str = "" + + @classmethod + def mint(cls, window_start: int, window_end: int, comm_budget_bytes: int, + resource_budget_digest: str, trigger_policy_id: str = "default", + signer: str = "", key: Optional[bytes] = None) -> "PassWindowTicket": + nonce = uuid.uuid4().hex[:12] + t = cls(window_start=window_start, window_end=window_end, + comm_budget_bytes=int(comm_budget_bytes), + resource_budget_digest=resource_budget_digest, + trigger_policy_id=trigger_policy_id, nonce=nonce, + signer=signer, sig="") + if key: + t.sig = t._sign(key) + return t + + def _canonical(self) -> str: + payload = { + "window_start": int(self.window_start), + "window_end": int(self.window_end), + "comm_budget_bytes": int(self.comm_budget_bytes), + "resource_budget_digest": self.resource_budget_digest, + "trigger_policy_id": self.trigger_policy_id, + "nonce": self.nonce, + "signer": self.signer, + } + return json.dumps(payload, sort_keys=True, separators=(",", ":")) + + def _sign(self, key: bytes) -> str: + h = hmac.new(key, self._canonical().encode("utf-8"), hashlib.blake2b) + return h.hexdigest()[:64] + + def serialize(self) -> bytes: + obj = { + "window_start": self.window_start, + "window_end": self.window_end, + "comm_budget_bytes": self.comm_budget_bytes, + "resource_budget_digest": self.resource_budget_digest, + "trigger_policy_id": self.trigger_policy_id, + "nonce": self.nonce, + "signer": self.signer, + "sig": self.sig, + } + return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8") + + def ticket_hash(self) -> str: + # external pointer: sha256 of canonical payload (no sig) + return hashlib.sha256(self._canonical().encode("utf-8")).hexdigest() + + def verify(self, key: bytes) -> bool: + if not self.sig: + return False + expected = self._sign(key) + return hmac.compare_digest(expected, self.sig) + + @classmethod + def deserialize(cls, b: bytes) -> "PassWindowTicket": + obj = json.loads(b.decode("utf-8")) + return cls(window_start=int(obj.get("window_start", 0)), + window_end=int(obj.get("window_end", 0)), + comm_budget_bytes=int(obj.get("comm_budget_bytes", 0)), + resource_budget_digest=obj.get("resource_budget_digest", ""), + trigger_policy_id=obj.get("trigger_policy_id", ""), + nonce=obj.get("nonce", ""), + signer=obj.get("signer", ""), + sig=obj.get("sig", "")) diff --git a/tests/test_mfr_v2.py b/tests/test_mfr_v2.py new file mode 100644 index 0000000..4425fdc --- /dev/null +++ b/tests/test_mfr_v2.py @@ -0,0 +1,29 @@ +import os +from gravityweave.mfr_v2 import MutualFeasibilityReceiptV2 + + +def test_mfr_v2_sign_and_size(): + key = b"test-key-12345" + proposal_hash = "abcd1234" + invariants = [1, 2, 7] + m = MutualFeasibilityReceiptV2.create( + proposal_hash=proposal_hash, + invariant_id_set=invariants, + decision="ok", + uncertainty_pct=5, + round_nonce=42, + pass_ticket_hash="", + execution_hash_skim="deadbeef", + signer="node-A", + key=key, + ) + + assert m.verify(key) + s = m.serialize() + # ensure size is small (<= 512 bytes as requested by spec) + assert len(s) <= 512 + + # roundtrip + m2 = MutualFeasibilityReceiptV2.deserialize(s) + assert m2.proposal_hash == proposal_hash + assert m2.invariant_id_set == invariants diff --git a/tests/test_ticket.py b/tests/test_ticket.py new file mode 100644 index 0000000..595ce1f --- /dev/null +++ b/tests/test_ticket.py @@ -0,0 +1,28 @@ +from gravityweave.ticket import PassWindowTicket + + +def test_ticket_mint_and_verify(): + key = b"ticket-key-xyz" + start = 1700000000 + end = start + 60 + t = PassWindowTicket.mint( + window_start=start, + window_end=end, + comm_budget_bytes=2048, + resource_budget_digest="rbdig-0123", + trigger_policy_id="policy-1", + signer="authority-1", + key=key, + ) + + assert t.verify(key) + serialized = t.serialize() + assert len(serialized) < 1024 + # ticket hash is stable and hex + th = t.ticket_hash() + assert isinstance(th, str) and len(th) == 64 + + # roundtrip + t2 = PassWindowTicket.deserialize(serialized) + assert t2.window_start == start + assert t2.window_end == end