vitnify 0.0.0__py3-none-any.whl

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.
vitnify/__init__.py ADDED
File without changes
File without changes
File without changes
@@ -0,0 +1,181 @@
1
+ """Content-addressed store + Merkle ledger — the provenance backbone of PCK.
2
+
3
+ Every source chunk is addressed by its cryptographic hash. A Merkle tree over those
4
+ hashes yields a single ``root`` that attests the whole corpus. Any single chunk can
5
+ then carry an *inclusion proof*: a short path of sibling hashes that recomputes the
6
+ root. The guarantees this buys, and why they matter for AI answers:
7
+
8
+ * **Tamper-evidence** — change one byte of one chunk and the root changes.
9
+ * **Independent verifiability** — anyone holding the root can check a chunk's proof
10
+ without trusting the party that produced it (no model, no server, no secret key).
11
+ * **Fabrication is uncredentialed** — a made-up "source" has no leaf in the tree, so
12
+ it cannot produce a valid proof. This is the cryptographic half of catching a
13
+ hallucination: a grounded claim points at a chunk with a valid proof; an invented
14
+ one cannot.
15
+
16
+ This mirrors the BLAKE3 + Merkle design of a native runtime's ``cas_store``; here we
17
+ keep a dependency-light pure-Python implementation over ``blake3``. BLAKE3 is a hard
18
+ dependency and DEFINES the wire format: a ``blake2b`` digest is a different value under
19
+ the same ``hash_name``, so there is NO silent stdlib fallback -- one would make honest
20
+ proofs indistinguishable from forgeries. A missing blake3 fails loudly (matching
21
+ ``events.py`` / ``certificate.py``), never silently downgrades.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+ from typing import Optional
27
+
28
+ import blake3 as _blake3
29
+
30
+ _HASH_NAME = "blake3"
31
+
32
+ def _digest(b: bytes) -> str:
33
+ return _blake3.blake3(b).hexdigest()
34
+
35
+
36
+ HASH_NAME = _HASH_NAME
37
+
38
+
39
+ def hash_bytes(b: bytes) -> str:
40
+ """Content address of raw bytes (hex)."""
41
+ return _digest(b)
42
+
43
+
44
+ def hash_text(s: str) -> str:
45
+ """Content address of a text chunk (UTF-8, hex)."""
46
+ return _digest(s.encode("utf-8"))
47
+
48
+
49
+ def _hash_pair(left: str, right: str) -> str:
50
+ """Internal Merkle node = hash of the concatenated child hex digests."""
51
+ return _digest((left + right).encode("utf-8"))
52
+
53
+
54
+ # --- inclusion proof -------------------------------------------------------------
55
+
56
+ @dataclass(frozen=True)
57
+ class ProofStep:
58
+ """One level of a Merkle inclusion path."""
59
+
60
+ sibling: str # hex digest of the sibling node
61
+ on_right: bool # True if the *current* node is the right child at this level
62
+
63
+ def to_json(self) -> dict:
64
+ return {"sibling": self.sibling, "on_right": self.on_right}
65
+
66
+ @staticmethod
67
+ def from_json(d: dict) -> "ProofStep":
68
+ return ProofStep(sibling=d["sibling"], on_right=bool(d["on_right"]))
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class InclusionProof:
73
+ """A self-contained proof that ``leaf`` is committed under ``root``.
74
+
75
+ Carries everything a third party needs to re-verify against a *published* root:
76
+ the leaf hash, the sibling path, and the index. No tree, server, or key required.
77
+ """
78
+
79
+ leaf: str
80
+ index: int
81
+ path: list[ProofStep]
82
+ hash_name: str = HASH_NAME
83
+
84
+ def verify(self, root: str) -> bool:
85
+ # A proof carries the hash suite it was built under. This module only speaks
86
+ # BLAKE3, so a proof committed under any other suite cannot be recomputed here
87
+ # -- reject rather than silently recompute under the wrong hash.
88
+ if self.hash_name != HASH_NAME:
89
+ return False
90
+ return verify_proof(self.leaf, self.path, root)
91
+
92
+ def to_json(self) -> dict:
93
+ return {
94
+ "leaf": self.leaf,
95
+ "index": self.index,
96
+ "hash": self.hash_name,
97
+ "path": [s.to_json() for s in self.path],
98
+ }
99
+
100
+ @staticmethod
101
+ def from_json(d: dict) -> "InclusionProof":
102
+ return InclusionProof(
103
+ leaf=d["leaf"],
104
+ index=int(d["index"]),
105
+ path=[ProofStep.from_json(s) for s in d["path"]],
106
+ hash_name=d.get("hash", HASH_NAME),
107
+ )
108
+
109
+
110
+ def verify_proof(leaf: str, path: list[ProofStep], root: str) -> bool:
111
+ """Recompute the root from ``leaf`` and ``path``; True iff it equals ``root``."""
112
+ h = leaf
113
+ for step in path:
114
+ h = _hash_pair(step.sibling, h) if step.on_right else _hash_pair(h, step.sibling)
115
+ return h == root
116
+
117
+
118
+ # --- the tree / store ------------------------------------------------------------
119
+
120
+ @dataclass
121
+ class MerkleCAS:
122
+ """Content-addressed store with a Merkle commitment over its chunks.
123
+
124
+ Odd layers duplicate the final node (Bitcoin-style), so a proof always exists for
125
+ every leaf. Construction is deterministic in the chunk order.
126
+ """
127
+
128
+ chunks: list[str]
129
+ leaves: list[str] = field(default_factory=list)
130
+ layers: list[list[str]] = field(default_factory=list)
131
+ _index_of_hash: dict[str, int] = field(default_factory=dict)
132
+
133
+ def __post_init__(self) -> None:
134
+ if not self.chunks:
135
+ raise ValueError("MerkleCAS requires at least one chunk")
136
+ self.leaves = [hash_text(c) for c in self.chunks]
137
+ # first occurrence wins if a chunk repeats
138
+ for i, h in enumerate(self.leaves):
139
+ self._index_of_hash.setdefault(h, i)
140
+ self.layers = self._build(self.leaves)
141
+
142
+ @staticmethod
143
+ def _build(leaves: list[str]) -> list[list[str]]:
144
+ layers = [leaves[:]]
145
+ while len(layers[-1]) > 1:
146
+ cur = layers[-1]
147
+ nxt = [
148
+ _hash_pair(cur[i], cur[i + 1] if i + 1 < len(cur) else cur[i])
149
+ for i in range(0, len(cur), 2)
150
+ ]
151
+ layers.append(nxt)
152
+ return layers
153
+
154
+ @property
155
+ def root(self) -> str:
156
+ return self.layers[-1][0]
157
+
158
+ def index_of(self, chunk_or_hash: str) -> Optional[int]:
159
+ """Index of a chunk by its raw text or by its hash; None if absent."""
160
+ if chunk_or_hash in self._index_of_hash:
161
+ return self._index_of_hash[chunk_or_hash]
162
+ return self._index_of_hash.get(hash_text(chunk_or_hash))
163
+
164
+ def contains(self, chunk_or_hash: str) -> bool:
165
+ return self.index_of(chunk_or_hash) is not None
166
+
167
+ def prove_index(self, idx: int) -> InclusionProof:
168
+ if not 0 <= idx < len(self.leaves):
169
+ raise IndexError(idx)
170
+ path: list[ProofStep] = []
171
+ i = idx
172
+ for layer in self.layers[:-1]:
173
+ sib = i ^ 1 if (i ^ 1) < len(layer) else i # duplicated node is its own sibling
174
+ path.append(ProofStep(sibling=layer[sib], on_right=bool(i & 1)))
175
+ i //= 2
176
+ return InclusionProof(leaf=self.leaves[idx], index=idx, path=path)
177
+
178
+ def prove(self, chunk_or_hash: str) -> Optional[InclusionProof]:
179
+ """Inclusion proof for a chunk (by text or hash); None if not in the corpus."""
180
+ idx = self.index_of(chunk_or_hash)
181
+ return None if idx is None else self.prove_index(idx)
vitnify/capability.py ADDED
@@ -0,0 +1,77 @@
1
+ """Userspace, kernel-free capability broker. Containment by construction: every
2
+ side-effecting tool call routes through here, and a tool the run wasn't granted is
3
+ simply unreachable -- a jailbreak that reaches an ungranted tool hits a wall, not a
4
+ classifier. Every call (allow or deny) is recorded to the event log.
5
+
6
+ SAFE BY DEFAULT (0.4.0). The broker **redacts**: it commits a SALTED hash of each
7
+ tool's args/results into the receipt and keeps the cleartext in an org-held
8
+ :class:`~vitnify.redact.Vault` (``broker.vault``), so PHI/secrets never enter the
9
+ signed record -- on allow AND deny. Disclose one event at a time with an inclusion
10
+ proof (see :mod:`vitnify.redact`).
11
+
12
+ Pass ``allow_cleartext=True`` to record payloads in cleartext instead (the pre-0.4.0
13
+ behaviour) -- ONLY where the arguments and results are not sensitive; a redacted log
14
+ is the safe choice for regulated data.
15
+ """
16
+ from __future__ import annotations
17
+ import hashlib
18
+ import os
19
+ from .events import EventLog, Kind
20
+
21
+
22
+ def _rh(x) -> str:
23
+ return hashlib.blake2b(repr(x).encode(), digest_size=16).hexdigest()
24
+
25
+
26
+ class Broker:
27
+ def __init__(self, capabilities, tools: dict, log: EventLog, *, vault=None,
28
+ allow_cleartext: bool = False, replay=None):
29
+ self.caps = set(capabilities)
30
+ self.tools = tools
31
+ self.log = log
32
+ self.replay = replay # if set: recorded ALLOW results to re-inject
33
+ self.allow_cleartext = allow_cleartext
34
+ if allow_cleartext:
35
+ self.vault = None
36
+ else:
37
+ from .redact import Vault # lazy: avoids an import cycle (redact imports Broker)
38
+ self.vault = vault if vault is not None else Vault()
39
+
40
+ def call(self, tool: str, *args):
41
+ args = list(args)
42
+ allowed = tool in self.caps
43
+
44
+ if self.allow_cleartext:
45
+ # OPT-OUT: record payloads in cleartext (only for non-sensitive data).
46
+ if not allowed:
47
+ self.log.append(Kind.TOOL_CALL, {"tool": tool, "args": args, "decision": "DENY"})
48
+ return False, None
49
+ result = self.replay.pop(0) if self.replay is not None else self.tools[tool](*args)
50
+ self.log.append(Kind.TOOL_CALL, {"tool": tool, "args": args, "decision": "ALLOW",
51
+ "result": result, "result_hash": _rh(result)})
52
+ return True, result
53
+
54
+ # SAFE DEFAULT: commit SALTED hashes; cleartext goes to the vault, not the receipt.
55
+ from .redact import _commit
56
+ idx = len(self.log)
57
+ asalt = os.urandom(16)
58
+ if not allowed:
59
+ self.log.append(Kind.TOOL_CALL,
60
+ {"tool": tool, "args_commit": _commit(asalt, args), "decision": "DENY"})
61
+ self.vault.put(idx, args_salt=asalt, args=args)
62
+ return False, None
63
+ result = self.replay.pop(0) if self.replay is not None else self.tools[tool](*args)
64
+ rsalt = os.urandom(16)
65
+ self.log.append(Kind.TOOL_CALL,
66
+ {"tool": tool, "args_commit": _commit(asalt, args),
67
+ "decision": "ALLOW", "result_commit": _commit(rsalt, result)})
68
+ self.vault.put(idx, args_salt=asalt, args=args, result_salt=rsalt, result=result)
69
+ return True, result
70
+
71
+
72
+ def recorded_tool_results(log: EventLog):
73
+ """ALLOW results for a replay, from a CLEARTEXT-mode log. For a redacted log the
74
+ results are in the vault -- use :func:`vitnify.redact.recorded_tool_results(log, vault)`."""
75
+ return [e.payload["result"] for e in log.events
76
+ if e.kind == Kind.TOOL_CALL.value and e.payload.get("decision") == "ALLOW"
77
+ and "result" in e.payload]
vitnify/certificate.py ADDED
@@ -0,0 +1,388 @@
1
+ """vitnify-receipt v1 -- the signed, self-verifying execution receipt.
2
+
3
+ One receipt binds an agent run into a single object:
4
+
5
+ program hash | granted capabilities | hash-chained event log (Merkle root + head)
6
+ | the engine's per-step model-computation digests
7
+
8
+ so an action can never be detached from the computation and context that produced
9
+ it. The receipt digest is BLAKE3 over a canonical body (matching the vitni-tensor
10
+ engine and the event log); the receipt is signed with ed25519 and embeds its own
11
+ public key, so ANYONE can verify it offline with no model, no network, and no secret.
12
+
13
+ Two verification levels:
14
+ * level 1 (integrity): recompute the Merkle root, chain head, and event count from
15
+ the raw events, and check the ed25519 signature. No model required.
16
+ * level 2 (recomputation): re-run each bound model step through vitni-tensor and
17
+ confirm every model_digest reproduces bit-for-bit.
18
+
19
+ verify_certificate() implements level 1 and is fully independent. HMAC signing is
20
+ retained only as a fallback for environments without an asymmetric key.
21
+ """
22
+ from __future__ import annotations
23
+ import os, sys, json, hmac
24
+ from dataclasses import dataclass, field, asdict
25
+ from .events import EventLog, Kind
26
+
27
+ from ._vendor.pck.cas import MerkleCAS # noqa: E402
28
+
29
+ FORMAT = "vitnify-receipt v2"
30
+ # A published receipt format is frozen; older receipts must stay verifiable across a
31
+ # verifier upgrade. The verifier accepts every version it has ever issued and
32
+ # reconstructs each receipt's signed body per its own `v` (see body()).
33
+ SUPPORTED_FORMATS = frozenset({"vitnify-receipt v1", FORMAT})
34
+
35
+ # The tool decisions that count as GATED -- the call passed through the capability wall
36
+ # (allow) or was refused by it (deny). Any other label ("observed", ...) is watch-only,
37
+ # not enforcement. This is the SINGLE SOURCE OF TRUTH for the containment predicate:
38
+ # verify_certificate and the human-facing viewer both call it, so the rule cannot drift
39
+ # between the dict a machine checks and the page a person reads.
40
+ GATED_DECISIONS = frozenset({"allow", "deny"})
41
+
42
+ # The mandatory integrity checks folded into `integrity_ok` -- always produced by
43
+ # verify_certificate. Module-level so a test can assert the verifier produces every one
44
+ # (a dropped key would otherwise weaken integrity_ok silently). `program_matches` is
45
+ # NOT here: it is optional (only when `program=` is supplied).
46
+ _INTEGRITY_KEYS = ("format", "kinds_known", "root_matches", "head_matches", "count_matches",
47
+ "model_digests_match", "sig_valid", "caps_consistent", "fields_match_version")
48
+
49
+
50
+ def decision_is_gated(decision) -> bool:
51
+ """True if a tool decision was gated by the capability wall (allow/deny), not merely
52
+ observed. Case- and whitespace-insensitive; fails closed on anything unrecognised."""
53
+ return str(decision).strip().lower() in GATED_DECISIONS
54
+
55
+
56
+ def _now_iso() -> str:
57
+ """Issuer-asserted UTC timestamp (second precision). Places the receipt in time
58
+ and, with the nonce, makes every receipt unique. NOT a trusted timestamp -- an
59
+ RFC 3161 token or the Verification Authority countersignature is what makes the
60
+ time non-repudiable; this defeats silent replay, not a determined backdater."""
61
+ from datetime import datetime, timezone
62
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
63
+
64
+ # BLAKE3 is a hard dependency and DEFINES the wire format: a blake2b digest is a
65
+ # different value, so there is no silent stdlib fallback (see events.py). A missing
66
+ # blake3 fails loudly rather than producing incompatible digests.
67
+ import blake3 as _blake3
68
+ def _digest32(b: bytes) -> str:
69
+ return _blake3.blake3(b).hexdigest()
70
+
71
+ # `cryptography` is imported lazily inside the ed25519 helpers, so HMAC-only signing
72
+ # works without it installed. (blake3, above, is a hard dependency -- there is no
73
+ # hash-free path; the module will not import without it.)
74
+
75
+
76
+ def _canon(o) -> str:
77
+ return json.dumps(o, sort_keys=True, separators=(",", ":"))
78
+
79
+
80
+ def gen_ed25519():
81
+ """Return (private_key, public_key_hex). Persist the private key to sign future runs."""
82
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
83
+ priv = Ed25519PrivateKey.generate()
84
+ return priv, priv.public_key().public_bytes_raw().hex()
85
+
86
+
87
+ def derive_program_hash(program, root=None) -> str:
88
+ """Derive a ``program_hash`` from the ACTUAL program, so the receipt binds what ran
89
+ instead of a caller-asserted label -- ``"literally anything I type"`` no longer
90
+ verifies against real code.
91
+
92
+ ``program`` is a file path, an iterable of paths, or raw ``bytes``. Returns
93
+ ``"sha256:<hex>"``. Pass the same value to :func:`issue_certificate` at issue time
94
+ and to :func:`verify_certificate` (``program=``) at verify time.
95
+
96
+ INJECTIVE by construction, matching the tier-1 digest discipline ("fields are
97
+ length-prefixed so concatenation is injective"): each entry binds its
98
+ **length-prefixed relative path** then its **length-prefixed content**. A raw
99
+ ``\\x00`` delimiter is unsafe because file content can contain ``\\x00`` (two files
100
+ then collide with one crafted file); a length prefix cannot. Entries are sorted by
101
+ **relative path** -- a TOTAL order, unlike basename, so a program with several
102
+ ``__init__.py`` is argument-order-independent -- and the relative path is bound, so
103
+ moving a file between directories changes the hash. Pass ``root`` to control what the
104
+ paths are relative to; otherwise paths are bound as given (pass relative paths for a
105
+ machine-independent hash).
106
+ """
107
+ import hashlib
108
+ import struct
109
+
110
+ def _lp(b: bytes) -> bytes: # length-prefixed: injective concatenation
111
+ return struct.pack(">Q", len(b)) + b
112
+
113
+ hsh = hashlib.sha256()
114
+ if isinstance(program, (bytes, bytearray)):
115
+ hsh.update(_lp(b"")) # anonymous single blob: empty relative path
116
+ hsh.update(_lp(bytes(program)))
117
+ else:
118
+ paths = [program] if isinstance(program, (str, os.PathLike)) else list(program)
119
+ entries = []
120
+ for p in paths:
121
+ rel = os.path.relpath(str(p), root) if root is not None else str(p)
122
+ rel = rel.replace(os.sep, "/") # stable across platforms
123
+ with open(p, "rb") as f:
124
+ entries.append((rel, f.read()))
125
+ for rel, data in sorted(entries, key=lambda e: e[0]): # total order by rel path
126
+ hsh.update(_lp(rel.encode("utf-8")))
127
+ hsh.update(_lp(data))
128
+ return "sha256:" + hsh.hexdigest()
129
+
130
+
131
+ @dataclass
132
+ class ExecutionCertificate:
133
+ program_hash: str
134
+ capabilities: list
135
+ event_root: str
136
+ n_events: int
137
+ head_hash: str
138
+ model_digests: list = field(default_factory=list) # engine per-step digests, bound by the log
139
+ issued_at: str | None = None # ISO-8601 UTC, issuer-asserted (see _now_iso)
140
+ nonce: str | None = None # random per receipt -- makes each receipt unique, non-replayable
141
+ run_id: str | None = None # identifies THIS run; distinct runs get distinct receipts
142
+ v: str = FORMAT
143
+ sig: str | None = None
144
+ sig_alg: str = "none"
145
+ pubkey: str | None = None # ed25519 public key (hex) -- verifier needs no secret
146
+
147
+ def body(self) -> dict:
148
+ # Everything here is signed, and the body is VERSIONED: v2+ additionally
149
+ # binds issued_at/nonce/run_id (which place the receipt in time and make it
150
+ # unique). Reconstructing the body per the receipt's OWN `v` is what lets a
151
+ # v2 verifier still verify a receipt signed under v1 -- a published format is
152
+ # frozen, so older receipts must remain verifiable.
153
+ b = {"v": self.v, "program_hash": self.program_hash,
154
+ "capabilities": sorted(self.capabilities), "event_root": self.event_root,
155
+ "n_events": self.n_events, "head_hash": self.head_hash,
156
+ "model_digests": list(self.model_digests)}
157
+ if self.v != "vitnify-receipt v1":
158
+ b["issued_at"] = self.issued_at
159
+ b["nonce"] = self.nonce
160
+ b["run_id"] = self.run_id
161
+ return b
162
+
163
+ def digest(self) -> str:
164
+ return _digest32(_canon(self.body()).encode())
165
+
166
+ def sign_ed25519(self, priv) -> "ExecutionCertificate":
167
+ self.sig = priv.sign(bytes.fromhex(self.digest())).hex()
168
+ self.pubkey = priv.public_key().public_bytes_raw().hex()
169
+ self.sig_alg = "ed25519"
170
+ return self
171
+
172
+ def sign_hmac(self, key: bytes) -> "ExecutionCertificate":
173
+ import hashlib
174
+ self.sig = hmac.new(key, self.digest().encode(), hashlib.blake2b).hexdigest()
175
+ self.sig_alg = "hmac-blake2b"
176
+ return self
177
+
178
+ def to_json(self) -> str:
179
+ return _canon(asdict(self))
180
+
181
+
182
+ def issue_certificate(program_hash, capabilities, log: EventLog, priv=None, key: bytes | None = None,
183
+ run_id: str | None = None):
184
+ """Build and sign a vitnify-receipt for a completed run.
185
+
186
+ The model-computation digests are read from the log's llm_call events, so the
187
+ receipt binds exactly the model steps that were recorded. `model_digests` is
188
+ EMPTY for a run with no recomputable model step (a hosted API, or a torch /
189
+ non-deterministic backend) -- such a receipt is integrity-only: it binds the
190
+ transcript, not the computation.
191
+
192
+ `program_hash` is CALLER-ASSERTED -- the receipt binds whatever string you pass;
193
+ it does not itself hash your agent's code. Pass a hash of the exact program or
194
+ config that ran if you want the receipt to pin it.
195
+
196
+ Each receipt is stamped with an issuer-asserted UTC time, a random nonce, and a
197
+ run id (pass `run_id` to set your own; a random one is used otherwise) so distinct
198
+ runs produce distinct, time-placeable receipts. ed25519 (self-verifying) is the
199
+ canonical signing path; pass `priv`.
200
+ """
201
+ if not log.events:
202
+ raise ValueError("cannot issue a receipt for an empty event log: record at "
203
+ "least one event first (there is nothing to commit)")
204
+ cas = MerkleCAS(log.chunks())
205
+ cert = ExecutionCertificate(
206
+ program_hash, sorted(set(capabilities)), cas.root, len(log), log.head(),
207
+ model_digests=log.model_digests(),
208
+ issued_at=_now_iso(), nonce=os.urandom(16).hex(),
209
+ run_id=run_id if run_id is not None else os.urandom(16).hex(),
210
+ )
211
+ if priv is not None:
212
+ cert.sign_ed25519(priv)
213
+ elif key is not None:
214
+ cert.sign_hmac(key)
215
+ return cert, cas
216
+
217
+
218
+ def verify_certificate(cert: ExecutionCertificate, log: EventLog, key: bytes | None = None,
219
+ pinned_pubkeys=None, program=None, require_authority: bool = True) -> dict:
220
+ """Level-1 verification: recompute everything from the raw events; trust nothing.
221
+
222
+ Fails CLOSED. A receipt is only `ok` if a signature was actually checked and
223
+ passed: an unsigned receipt (`sig_alg="none"`), an unknown algorithm, or an
224
+ HMAC receipt presented without its key all yield `sig_valid=False`, so a
225
+ receipt that cannot be cryptographically verified can never report `ok=True`.
226
+
227
+ ed25519 receipts verify against their OWN embedded public key -- no secret
228
+ required -- but that proves signer continuity, not signer authority. Pass
229
+ `pinned_pubkeys` (an allow-list of hex ed25519 keys) to additionally require
230
+ the receipt was signed by a key you trust. (Level 2 -- recomputing each
231
+ model_digest through vitni-tensor -- is a separate step that additionally
232
+ needs the model weights.)
233
+ """
234
+ # An empty event log has no Merkle commitment, so no legitimately issued receipt
235
+ # carries one (issue_certificate commits >=1 event). A verifier must FAIL CLOSED on
236
+ # such degenerate/hostile input, never crash: compute the root only when there are
237
+ # events and treat its absence as a non-match, instead of letting MerkleCAS raise.
238
+ cas_root = MerkleCAS(log.chunks()).root if log.events else None
239
+ # Every event kind must be recognised. The semantic checks below filter events
240
+ # by an EXACT kind match, so an unknown kind ("TOOL_CALL", "toolcall", ...) would
241
+ # slip past them silently -- the same self-declared-label class as an unknown
242
+ # sig_alg or an unknown decision string. Fail closed here so the whole class is
243
+ # gone: an unrecognised kind (or decision, or algorithm, or format) never verifies.
244
+ known_kinds = {k.value for k in Kind}
245
+ checks = {
246
+ "format": cert.v in SUPPORTED_FORMATS,
247
+ "kinds_known": all(e.kind in known_kinds for e in log.events),
248
+ "root_matches": cas_root is not None and cas_root == cert.event_root,
249
+ "head_matches": log.head() == cert.head_hash,
250
+ "count_matches": len(log) == cert.n_events,
251
+ "model_digests_match": log.model_digests() == list(cert.model_digests),
252
+ }
253
+
254
+ # Signature -- fail closed. Only an actually-checked, passing signature sets
255
+ # this True; "none", an unknown alg, or keyless HMAC all leave it False, so an
256
+ # unverifiable receipt can never reach ok=True.
257
+ sig_valid = False
258
+ if cert.sig_alg == "ed25519" and cert.sig and cert.pubkey:
259
+ try:
260
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
261
+ Ed25519PublicKey.from_public_bytes(bytes.fromhex(cert.pubkey)).verify(
262
+ bytes.fromhex(cert.sig), bytes.fromhex(cert.digest()))
263
+ sig_valid = True
264
+ except Exception:
265
+ sig_valid = False
266
+ elif cert.sig_alg == "hmac-blake2b" and cert.sig and key is not None:
267
+ import hashlib
268
+ sig_valid = hmac.compare_digest(
269
+ cert.sig, hmac.new(key, cert.digest().encode(), hashlib.blake2b).hexdigest())
270
+ checks["sig_valid"] = sig_valid
271
+
272
+ # Capability consistency -- the receipt must PROVE no ungranted tool executed,
273
+ # not just carry a capability list. Every tool call must be within the declared,
274
+ # signed-over capability set OR a CLEAN DENIAL (decision exactly "deny",
275
+ # case/space-insensitive, with no result). Keying only off decision=="allow"
276
+ # let a forged log slip an ungranted, result-bearing call through by relabelling
277
+ # the decision ("PERMIT", " allow", omitted...) -- F11. Failing closed on
278
+ # anything that is not a clean denial removes that, whatever string it carries.
279
+ granted = set(cert.capabilities)
280
+
281
+ def _clean_denial(p: dict) -> bool:
282
+ # A denial is clean ONLY if it carries no result key AT ALL. This is a
283
+ # security boundary, not ergonomics: the most dangerous tool shape is a
284
+ # side-effecting call that returns None (send_email, wire_transfer,
285
+ # delete_record). An UNGRANTED one that actually executed and is logged
286
+ # {"decision":"deny","result":None} must NOT pass -- yet an explicit None
287
+ # and an absent key are indistinguishable by value, so accepting None (the
288
+ # 0.2.13 loosening, now reverted) let a real side effect masquerade as a
289
+ # block through any non-Broker wrapper. Every enforced deny site already
290
+ # OMITS the key; a genuine block never writes it. If the ergonomics are
291
+ # wanted later, the sound form is a POSITIVE Broker assertion
292
+ # (``executed: false``), never an absence you cannot distinguish from a
293
+ # None return. Guarded by tests/test_attack_matrix.py::...none_result.
294
+ return (str(p.get("decision", "")).strip().lower() == "deny"
295
+ and "result" not in p and "result_hash" not in p)
296
+
297
+ checks["caps_consistent"] = all(
298
+ e.payload.get("tool") in granted or _clean_denial(e.payload)
299
+ for e in log.events
300
+ if e.kind == Kind.TOOL_CALL.value
301
+ )
302
+
303
+ # A verified receipt must carry NO data field its version does not sign. The v1
304
+ # body binds none of the v2 issuance fields, so a v1 receipt with issued_at /
305
+ # nonce / run_id set is carrying an unsigned, forgeable value (e.g. a backdated
306
+ # issued_at) -- reject it, so ok=True never blesses data outside the signature.
307
+ checks["fields_match_version"] = not (
308
+ cert.v == "vitnify-receipt v1"
309
+ and any(x is not None for x in (cert.issued_at, cert.nonce, cert.run_id)))
310
+
311
+ # Program binding -- an INTEGRITY check (computable offline, no trust root).
312
+ # `program_hash` is caller-asserted by default; supplying `program` (the ACTUAL code:
313
+ # a path, iterable of paths, or bytes) recomputes derive_program_hash and CONFIRMS the
314
+ # receipt names that program, turning a self-asserted label into a checked binding.
315
+ if program is not None:
316
+ checks["program_matches"] = (cert.program_hash == derive_program_hash(program))
317
+
318
+ # -------------------------------------------------------------- the verdict, SPLIT
319
+ # A receipt answers TWO distinct questions, and collapsing them into one boolean is
320
+ # wrong for somebody either way -- 0.3.x let a forgery read ok=True; 0.4.0 made an
321
+ # honest receipt read ok=False to a stranger with no trust root, indistinguishable
322
+ # from tampering. Keep them distinct:
323
+ #
324
+ # integrity_ok -- is this transcript internally consistent and validly signed by
325
+ # WHOEVER signed it (root/head/count, signature, caps, program)?
326
+ # Answerable by ANYONE, offline, with no model and no secret.
327
+ # authority_ok -- was the signer an APPROVED runtime? Needs a trust root, so it is
328
+ # True / False / None(unestablished). A stranger offline can never
329
+ # answer it; None must NOT be reported as a bare False that reads as
330
+ # "forged".
331
+ #
332
+ # The spec keeps signer pinning OPTIONAL (for integrity); this adds an explicit
333
+ # authority verdict on top, so the two never contradict.
334
+ # Mandatory integrity checks -- ALWAYS produced above. A missing one is a bug, so
335
+ # default to False (fail CLOSED, never open): a check silently dropped in a refactor,
336
+ # or a typo in this tuple, makes integrity_ok False, and the honest-receipt tests then
337
+ # fail LOUDLY rather than a hole passing quietly. Coverage is asserted in
338
+ # tests/test_safe_defaults.py::test_integrity_tuple_is_fully_produced.
339
+ integ = all(checks.get(k, False) for k in _INTEGRITY_KEYS)
340
+ if "program_matches" in checks: # optional: only when `program=` was supplied
341
+ integ = integ and checks["program_matches"]
342
+ checks["integrity_ok"] = integ
343
+
344
+ if pinned_pubkeys is not None:
345
+ authority_ok = bool(sig_valid and cert.sig_alg == "ed25519"
346
+ and cert.pubkey in set(pinned_pubkeys))
347
+ else:
348
+ authority_ok = None # no trust root -> UNESTABLISHED, not False
349
+ checks["authority_ok"] = authority_ok
350
+ checks["authority"] = ("verified" if authority_ok is True else
351
+ "rejected: signer not on the pinned allow-list" if authority_ok is False else
352
+ "unestablished: no trust root supplied (pass pinned_pubkeys)")
353
+ checks["signer_pinned"] = authority_ok is True # back-compat alias
354
+
355
+ # Enforcement vs observation (separate property, never folded into ok). An "allow"/
356
+ # "deny" decision was GATED by the wall; any other value ("observed", ...) was merely
357
+ # WATCHED, so the receipt is a tamper-evident transcript, not proof of containment.
358
+ checks["containment_enforced"] = all(
359
+ decision_is_gated(e.payload.get("decision"))
360
+ for e in log.events
361
+ if e.kind == Kind.TOOL_CALL.value
362
+ )
363
+
364
+ # `ok` = integrity AND an authorised signer (default). require_authority=False makes
365
+ # `ok` the integrity-only verdict (authority is still reported, just not required) --
366
+ # the answer a stranger CAN compute offline.
367
+ checks["ok"] = bool(checks["integrity_ok"] and (authority_ok is True if require_authority else True))
368
+ return checks
369
+
370
+
371
+ def verify_authorized(cert: ExecutionCertificate, log: EventLog, pinned_pubkeys, **kw) -> dict:
372
+ """Production verification that requires an AUTHORISED signer.
373
+
374
+ `verify_certificate` proves integrity and signer *continuity* from a receipt's own
375
+ embedded key -- but a forger can re-sign an edited receipt with their OWN key and it
376
+ still self-verifies (the honest limitation, kept as an xfail in the attack matrix).
377
+ Authority requires anchoring the signer to keys you trust. This wrapper FAILS CLOSED
378
+ unless `pinned_pubkeys` is a non-empty allow-list AND the signer is on it, so a
379
+ re-signed forgery can never report `ok=True`, and neither can a call that simply
380
+ forgot to pin. Use this -- not the bare verifier -- anywhere signer authority matters.
381
+
382
+ The allow-list is the software anchor; the strongest form pins a TPM/enclave-resident
383
+ key so the private key cannot leave an approved runtime (future hardening).
384
+ """
385
+ if not pinned_pubkeys:
386
+ return {"ok": False, "signer_pinned": False, "containment_enforced": False,
387
+ "error": "no pinned signer allow-list: signer authority cannot be established"}
388
+ return verify_certificate(cert, log, pinned_pubkeys=pinned_pubkeys, **kw)