pramana-verify 0.0.1__tar.gz

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.
@@ -0,0 +1,15 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pramana/
5
+ .pytest_cache/
6
+ web/node_modules/
7
+ web/dist/
8
+ # Secrets. The trailing slash this line used to have (`.env/`) matched only a
9
+ # *directory* named .env, never the file — which is how .env ended up committed
10
+ # in a1434d1 with a live Postgres password and S3 keys in it. Adding it here
11
+ # does not remove it from history: those credentials must be rotated, and the
12
+ # commit purged, separately.
13
+ .env
14
+ .env.*
15
+ !.env.example
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.5
2
+ Name: pramana-verify
3
+ Version: 0.0.1
4
+ Summary: Independently verify a Pramana evidence bundle — offline, with no Pramana account.
5
+ Project-URL: Homepage, https://www.reliai.in
6
+ Project-URL: Documentation, https://www.reliai.in/docs/
7
+ Author-email: Sahil Pandey <sahil@reliai.in>
8
+ License-Expression: Apache-2.0
9
+ Keywords: ai-agents,audit,compliance,ed25519,evidence,merkle
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Legal Industry
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Security :: Cryptography
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: cryptography>=42
19
+ Description-Content-Type: text/markdown
20
+
21
+ # pramana-verify
22
+
23
+ Independently check that a Pramana evidence bundle has not been altered.
24
+
25
+ Pramana records what an AI agent did. Every recorded event is fingerprinted with SHA-256 and
26
+ chained to the one before it, the chain is summarised into a Merkle root, and that root is signed
27
+ with Ed25519. This tool checks all of that, **on your machine, with no network access and no
28
+ Pramana account.**
29
+
30
+ It exists so that you do not have to trust Pramana, or the company whose agent produced the
31
+ bundle, in order to believe the bundle.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install pramana-verify
37
+ ```
38
+
39
+ One dependency (`cryptography`). Nothing else from the Pramana platform is required or installed.
40
+
41
+ ## Use
42
+
43
+ ```bash
44
+ pramana-verify bundle.json --pubkey <hex>
45
+ ```
46
+
47
+ `--pubkey` takes either the public key as hex, or a path to a file containing it.
48
+
49
+ **A passing bundle:**
50
+
51
+ ```
52
+ OK — 428 event(s), merkle_root=3f9c1a...
53
+ ```
54
+
55
+ **A bundle that has been altered:**
56
+
57
+ ```
58
+ TAMPERED / INVALID:
59
+ - event 17 (ev-8c21): hash chain broken — prev_hash does not match event 16
60
+ ```
61
+
62
+ Exit code is `0` when the bundle verifies and `1` when it does not, so this can be wired into an
63
+ automated compliance check.
64
+
65
+ ## Getting the public key
66
+
67
+ **Obtain the public key out of band** — from Pramana directly, from your own records, or from
68
+ whoever you are auditing, through a channel separate from the bundle itself.
69
+
70
+ This tool will never read a key from inside the bundle, deliberately. A bundle carrying its own
71
+ verification key proves nothing: anyone who altered the contents could re-sign them with a key of
72
+ their own and embed that instead.
73
+
74
+ ## What a passing result proves
75
+
76
+ - **Nothing in the bundle has been changed since it was signed.** Not one character of a prompt, a
77
+ response, a tool argument, a timestamp or an ordering. Any edit breaks the hash chain, and the
78
+ tool names the event where it broke.
79
+ - **The bundle was signed by the holder of the private key** matching the public key you supplied.
80
+
81
+ ## What it does not prove
82
+
83
+ - **That the recording was complete or honest at the moment it was made.** Cryptography proves
84
+ nothing has been altered *since signing*. It cannot prove that what was captured was everything
85
+ that happened.
86
+ - **That the agent behaved correctly.** This is a proof of record, not a judgement of conduct.
87
+
88
+ Those two limits are inherent to any signed audit record, and stating them is part of using one
89
+ properly.
90
+
91
+ ## Offline by design
92
+
93
+ This tool makes no network calls of any kind. You can verify a bundle on an air-gapped machine,
94
+ and you can verify a bundle years after it was produced, without Pramana existing.
95
+
96
+ ---
97
+
98
+ More about the platform that produces these bundles: https://www.reliai.in
@@ -0,0 +1,78 @@
1
+ # pramana-verify
2
+
3
+ Independently check that a Pramana evidence bundle has not been altered.
4
+
5
+ Pramana records what an AI agent did. Every recorded event is fingerprinted with SHA-256 and
6
+ chained to the one before it, the chain is summarised into a Merkle root, and that root is signed
7
+ with Ed25519. This tool checks all of that, **on your machine, with no network access and no
8
+ Pramana account.**
9
+
10
+ It exists so that you do not have to trust Pramana, or the company whose agent produced the
11
+ bundle, in order to believe the bundle.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install pramana-verify
17
+ ```
18
+
19
+ One dependency (`cryptography`). Nothing else from the Pramana platform is required or installed.
20
+
21
+ ## Use
22
+
23
+ ```bash
24
+ pramana-verify bundle.json --pubkey <hex>
25
+ ```
26
+
27
+ `--pubkey` takes either the public key as hex, or a path to a file containing it.
28
+
29
+ **A passing bundle:**
30
+
31
+ ```
32
+ OK — 428 event(s), merkle_root=3f9c1a...
33
+ ```
34
+
35
+ **A bundle that has been altered:**
36
+
37
+ ```
38
+ TAMPERED / INVALID:
39
+ - event 17 (ev-8c21): hash chain broken — prev_hash does not match event 16
40
+ ```
41
+
42
+ Exit code is `0` when the bundle verifies and `1` when it does not, so this can be wired into an
43
+ automated compliance check.
44
+
45
+ ## Getting the public key
46
+
47
+ **Obtain the public key out of band** — from Pramana directly, from your own records, or from
48
+ whoever you are auditing, through a channel separate from the bundle itself.
49
+
50
+ This tool will never read a key from inside the bundle, deliberately. A bundle carrying its own
51
+ verification key proves nothing: anyone who altered the contents could re-sign them with a key of
52
+ their own and embed that instead.
53
+
54
+ ## What a passing result proves
55
+
56
+ - **Nothing in the bundle has been changed since it was signed.** Not one character of a prompt, a
57
+ response, a tool argument, a timestamp or an ordering. Any edit breaks the hash chain, and the
58
+ tool names the event where it broke.
59
+ - **The bundle was signed by the holder of the private key** matching the public key you supplied.
60
+
61
+ ## What it does not prove
62
+
63
+ - **That the recording was complete or honest at the moment it was made.** Cryptography proves
64
+ nothing has been altered *since signing*. It cannot prove that what was captured was everything
65
+ that happened.
66
+ - **That the agent behaved correctly.** This is a proof of record, not a judgement of conduct.
67
+
68
+ Those two limits are inherent to any signed audit record, and stating them is part of using one
69
+ properly.
70
+
71
+ ## Offline by design
72
+
73
+ This tool makes no network calls of any kind. You can verify a bundle on an air-gapped machine,
74
+ and you can verify a bundle years after it was produced, without Pramana existing.
75
+
76
+ ---
77
+
78
+ More about the platform that produces these bundles: https://www.reliai.in
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "pramana-verify"
3
+ version = "0.0.1"
4
+ requires-python = ">=3.12"
5
+ description = "Independently verify a Pramana evidence bundle — offline, with no Pramana account."
6
+ readme = "README.md"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "Sahil Pandey", email = "sahil@reliai.in" }]
9
+ keywords = ["audit", "evidence", "ai-agents", "merkle", "ed25519", "compliance"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "Intended Audience :: Legal Industry",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Topic :: Security :: Cryptography",
17
+ "Topic :: Software Development :: Testing",
18
+ ]
19
+
20
+ # Deliberately just this: a regulator installs ONE small package to verify a
21
+ # bundle offline (docs/plan.md M4 repo layout: "standalone, zero-dependency
22
+ # verifier"). No pramana-core/pramana-proto/psycopg/boto3 — see canonical.py
23
+ # and merkle.py, which duplicate (not import) the tiny pieces of algorithm
24
+ # this needs, so this package never has to install the rest of the monorepo.
25
+ dependencies = ["cryptography>=42"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://www.reliai.in"
29
+ Documentation = "https://www.reliai.in/docs/"
30
+
31
+ [project.scripts]
32
+ pramana-verify = "pramana_verify.cli:main"
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/pramana_verify"]
40
+
41
+ [tool.hatch.metadata]
42
+ allow-direct-references = true
43
+
44
+ [dependency-groups]
45
+ # All test-only: a cross-check that this package's vendored
46
+ # canonicalize/merkle logic hasn't drifted from the "real" implementation,
47
+ # and fixtures for building real bundles to verify against. None of this is
48
+ # a runtime dependency of the shipped tool.
49
+ dev = ["pytest", "pramana-core", "pramana-proto", "pramana-store", "pramana-evidence"]
File without changes
@@ -0,0 +1,41 @@
1
+ """Vendored, not imported: must match pramana_core.hashing.canonical_json_bytes
2
+ and pramana_core.merkle.merkle_root byte-for-byte, or a legitimate bundle
3
+ would fail to verify. Cross-checked in tests/test_matches_pramana_core.py —
4
+ that test is this file's real spec.
5
+
6
+ Duplicated on purpose (docs/plan.md M4): this package ships standalone, with
7
+ no dependency on the rest of the monorepo.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import math
15
+ from typing import Any
16
+
17
+
18
+ class NonFiniteValueError(ValueError):
19
+ pass
20
+
21
+
22
+ def _reject_non_finite(obj: Any) -> Any:
23
+ if isinstance(obj, float) and not math.isfinite(obj):
24
+ raise NonFiniteValueError(f"non-finite float in canonicalized payload: {obj!r}")
25
+ if isinstance(obj, dict):
26
+ for k, v in obj.items():
27
+ _reject_non_finite(k)
28
+ _reject_non_finite(v)
29
+ elif isinstance(obj, (list, tuple)):
30
+ for v in obj:
31
+ _reject_non_finite(v)
32
+ return obj
33
+
34
+
35
+ def canonical_json_bytes(obj: Any) -> bytes:
36
+ _reject_non_finite(obj)
37
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
38
+
39
+
40
+ def sha256_hex(data: bytes) -> str:
41
+ return hashlib.sha256(data).hexdigest()
@@ -0,0 +1,48 @@
1
+ """`pramana-verify bundle.json --pubkey <hex-or-file>` (docs/LLD.md §8e).
2
+ No network, no Pramana platform install needed — see the package docstring
3
+ in canonical.py for why this tool has no dependency on the rest of the repo.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from pramana_verify.verifier import verify_bundle
14
+
15
+
16
+ def _load_pubkey_hex(value: str) -> str:
17
+ path = Path(value)
18
+ if path.exists():
19
+ return path.read_text().strip()
20
+ return value.strip()
21
+
22
+
23
+ def main(argv: list[str] | None = None) -> None:
24
+ parser = argparse.ArgumentParser(prog="pramana-verify")
25
+ parser.add_argument("bundle", help="path to an evidence bundle JSON file")
26
+ parser.add_argument(
27
+ "--pubkey",
28
+ required=True,
29
+ help="the platform's public key (hex), or a path to a file containing it — "
30
+ "obtained out-of-band, never trusted from inside the bundle",
31
+ )
32
+ args = parser.parse_args(argv)
33
+
34
+ bundle = json.loads(Path(args.bundle).read_text())
35
+ result = verify_bundle(bundle, _load_pubkey_hex(args.pubkey))
36
+
37
+ if result.ok:
38
+ print(f"OK — {len(bundle.get('events', []))} event(s), merkle_root={bundle.get('merkle_root')}")
39
+ return
40
+
41
+ print("TAMPERED / INVALID:", file=sys.stderr)
42
+ for err in result.errors:
43
+ print(f" - {err}", file=sys.stderr)
44
+ raise SystemExit(1)
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
@@ -0,0 +1,32 @@
1
+ """Vendored copy of pramana_core.merkle — see canonical.py's module
2
+ docstring for why. Cross-checked in tests/test_matches_pramana_core.py.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+
9
+
10
+ def _leaf_hash(data: bytes) -> bytes:
11
+ return hashlib.sha256(b"\x00" + data).digest()
12
+
13
+
14
+ def _node_hash(left: bytes, right: bytes) -> bytes:
15
+ return hashlib.sha256(b"\x01" + left + right).digest()
16
+
17
+
18
+ def _mth(leaves: list[bytes]) -> bytes:
19
+ n = len(leaves)
20
+ if n == 0:
21
+ return hashlib.sha256(b"").digest()
22
+ if n == 1:
23
+ return leaves[0]
24
+ k = 1
25
+ while k * 2 < n:
26
+ k *= 2
27
+ return _node_hash(_mth(leaves[:k]), _mth(leaves[k:]))
28
+
29
+
30
+ def merkle_root(leaf_hashes_hex: list[str]) -> str:
31
+ leaves = [_leaf_hash(bytes.fromhex(h)) for h in leaf_hashes_hex]
32
+ return _mth(leaves).hex()
@@ -0,0 +1,114 @@
1
+ """Offline evidence-bundle verification (docs/plan.md M4;
2
+ docs/PROJECT_SCOPE.md §6: "editing any stored event breaks the hash chain
3
+ and `verify` CLI detects it").
4
+
5
+ Trust model: the caller supplies the platform's public key explicitly. A key
6
+ embedded *inside* the bundle is never used for verification — only the
7
+ caller's own, out-of-band key counts (docs/LLD.md §6: "a regulator can
8
+ verify independently").
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from cryptography.exceptions import InvalidSignature
18
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
19
+
20
+ from pramana_verify.canonical import NonFiniteValueError, canonical_json_bytes
21
+ from pramana_verify.merkle import merkle_root
22
+
23
+
24
+ @dataclass
25
+ class VerificationResult:
26
+ ok: bool
27
+ errors: list[str] = field(default_factory=list)
28
+
29
+ def __bool__(self) -> bool:
30
+ return self.ok
31
+
32
+
33
+ def _event_this_hash(event: dict[str, Any], prev_hash: str) -> str:
34
+ """Must match pramana_core.chain.event_fields_for_hash + sha256_hex
35
+ exactly — this is the other half of that contract, verified independently.
36
+ """
37
+ fields = dict(event)
38
+ fields.pop("this_hash", None)
39
+ fields["prev_hash"] = prev_hash
40
+ return hashlib.sha256(canonical_json_bytes(fields)).hexdigest()
41
+
42
+
43
+ def verify_bundle(bundle: dict[str, Any], trusted_public_key_hex: str) -> VerificationResult:
44
+ errors: list[str] = []
45
+ events = bundle.get("events", [])
46
+
47
+ if not events:
48
+ return VerificationResult(ok=False, errors=["bundle has no events"])
49
+
50
+ # 1. Internal hash chain: each event's own content must hash to its
51
+ # declared this_hash, and each event's prev_hash must match the
52
+ # previous event's this_hash — catches edits and reordering alike.
53
+ prev_hash = events[0].get("prev_hash", "")
54
+ for i, event in enumerate(events):
55
+ label = f"event #{i} (seq={event.get('logical_seq')}, id={event.get('event_id')})"
56
+ if i > 0 and event.get("prev_hash", "") != prev_hash:
57
+ errors.append(f"{label}: prev_hash does not match the preceding event's this_hash")
58
+ if _event_this_hash(event, event.get("prev_hash", "")) != event.get("this_hash"):
59
+ errors.append(f"{label}: this_hash does not match its own content — this event was tampered with")
60
+ prev_hash = event.get("this_hash", "")
61
+
62
+ # 2. The merkle root over the (possibly-tampered) declared this_hashes
63
+ # must match what was signed.
64
+ if merkle_root([e.get("this_hash", "") for e in events]) != bundle.get("merkle_root"):
65
+ errors.append("merkle_root does not match the events in this bundle")
66
+
67
+ # 3. Payload integrity. `payload_ref` is the SHA-256 of the blob's
68
+ # canonical bytes, and step 1 already covers payload_ref via the event
69
+ # hash — but nothing above ever reads the blob *content*, so rewriting a
70
+ # recorded prompt or response inside `blobs` while leaving its ref
71
+ # alone would otherwise verify completely clean. That is the one edit an
72
+ # auditor cares most about catching, so recompute every blob's hash and
73
+ # require every referenced payload to actually be present.
74
+ blobs = bundle.get("blobs", {})
75
+ if not isinstance(blobs, dict):
76
+ errors.append("blobs is not an object")
77
+ blobs = {}
78
+ for ref, payload in blobs.items():
79
+ try:
80
+ actual = hashlib.sha256(canonical_json_bytes(payload)).hexdigest()
81
+ except NonFiniteValueError as e:
82
+ errors.append(f"payload {ref[:12]}...: not canonicalizable ({e})")
83
+ continue
84
+ if actual != ref:
85
+ errors.append(
86
+ f"payload {ref[:12]}...: content does not hash to its reference — "
87
+ "this recorded prompt or response was tampered with"
88
+ )
89
+ for i, event in enumerate(events):
90
+ ref = event.get("payload_ref", "")
91
+ if ref and ref not in blobs:
92
+ errors.append(
93
+ f"event #{i} (seq={event.get('logical_seq')}, id={event.get('event_id')}): "
94
+ f"payload_ref {ref[:12]}... has no payload in this bundle"
95
+ )
96
+
97
+ # 4. Signature over the bundle header, checked against the
98
+ # caller-supplied public key — never one embedded in the bundle.
99
+ header = canonical_json_bytes(
100
+ {
101
+ "tenant_id": bundle.get("tenant_id"),
102
+ "trace_id": bundle.get("trace_id"),
103
+ "from_seq": bundle.get("from_seq"),
104
+ "to_seq": bundle.get("to_seq"),
105
+ "merkle_root": bundle.get("merkle_root"),
106
+ }
107
+ )
108
+ try:
109
+ public_key = Ed25519PublicKey.from_public_bytes(bytes.fromhex(trusted_public_key_hex))
110
+ public_key.verify(bytes.fromhex(bundle.get("signature_hex", "")), header)
111
+ except (InvalidSignature, ValueError):
112
+ errors.append("signature does not verify against the supplied public key")
113
+
114
+ return VerificationResult(ok=not errors, errors=errors)
@@ -0,0 +1,18 @@
1
+ import os
2
+
3
+ import pytest
4
+ from pramana_store.postgres import apply_schema
5
+
6
+ CONNINFO = os.environ.get(
7
+ "PRAMANA_TEST_PG", "postgresql://postgres@127.0.0.1:55432/pramana"
8
+ )
9
+
10
+
11
+ @pytest.fixture(scope="session", autouse=True)
12
+ def _schema():
13
+ apply_schema(CONNINFO)
14
+
15
+
16
+ @pytest.fixture
17
+ def conninfo():
18
+ return CONNINFO
@@ -0,0 +1,39 @@
1
+ """The real spec for canonical.py/merkle.py: they must produce byte-for-byte
2
+ identical output to pramana_core's versions. This is a dev-only, test-time
3
+ dependency on pramana_core — never a runtime one (see pyproject.toml).
4
+ """
5
+
6
+ import hashlib
7
+
8
+ import pramana_core.hashing as core_hashing
9
+ import pramana_core.merkle as core_merkle
10
+ import pramana_verify.canonical as verify_canonical
11
+ import pramana_verify.merkle as verify_merkle
12
+
13
+
14
+ def test_canonical_json_bytes_matches():
15
+ samples = [
16
+ {"b": 1, "a": 2},
17
+ {"unicode": "café", "nested": {"x": [1, 2, 3]}},
18
+ [],
19
+ {},
20
+ {"z": None, "a": True},
21
+ ]
22
+ for obj in samples:
23
+ assert verify_canonical.canonical_json_bytes(obj) == core_hashing.canonical_json_bytes(obj)
24
+
25
+
26
+ def test_sha256_hex_matches():
27
+ data = b"some bytes"
28
+ assert verify_canonical.sha256_hex(data) == core_hashing.sha256_hex(data)
29
+
30
+
31
+ def test_merkle_root_matches():
32
+ leaves = [hashlib.sha256(str(i).encode()).hexdigest() for i in range(7)]
33
+ assert verify_merkle.merkle_root(leaves) == core_merkle.merkle_root(leaves)
34
+
35
+
36
+ def test_merkle_root_matches_for_various_sizes():
37
+ for n in range(15):
38
+ leaves = [hashlib.sha256(str(i).encode()).hexdigest() for i in range(n)]
39
+ assert verify_merkle.merkle_root(leaves) == core_merkle.merkle_root(leaves)
@@ -0,0 +1,164 @@
1
+ """docs/plan.md §7 acceptance test: `test_verify_cli_offline`.
2
+ docs/PROJECT_SCOPE.md §6: "editing any stored event breaks the hash chain
3
+ and `verify` CLI detects it"; "a bundle verifies... with the public CLI (no
4
+ server)".
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import uuid
11
+
12
+ from pramana_core.hashing import canonical_json_bytes
13
+ from pramana_core.signing import generate_keypair
14
+ from pramana_evidence.bundle import build_bundle
15
+ from pramana_proto.v1.event_pb2 import LLM_CALL, Event
16
+ from pramana_store.blob import LocalBlobStore
17
+ from pramana_store.postgres import PostgresEventRepository
18
+ from pramana_verify.verifier import verify_bundle
19
+
20
+
21
+ def _make_bundle(conninfo, tmp_path, n=3):
22
+ tenant_id, trace_id = f"tenant-{uuid.uuid4()}", f"trace-{uuid.uuid4()}"
23
+ repo = PostgresEventRepository(conninfo, tenant_id=tenant_id)
24
+ blob_store = LocalBlobStore(tmp_path, tenant_id=tenant_id)
25
+ for i in range(n):
26
+ # canonical_json_bytes, not json.dumps — this must match the real
27
+ # record path (pramana.canonical.dump_blob), or payload_ref won't be
28
+ # the content hash of what's actually stored. The fixture used plain
29
+ # json.dumps for a long time, and that discrepancy is precisely why
30
+ # nobody noticed verify_bundle never checked blob content at all.
31
+ payload_ref = blob_store.put(canonical_json_bytes({"input": {"i": i}, "output": {"ok": True}}))
32
+ repo.append_if_new(
33
+ Event(
34
+ event_id=str(uuid.uuid4()),
35
+ tenant_id=tenant_id,
36
+ trace_id=trace_id,
37
+ agent_id="default",
38
+ call_site_id="cs1",
39
+ call_site_ordinal=i,
40
+ logical_seq=i,
41
+ kind=LLM_CALL,
42
+ input_hash=f"ih{i}",
43
+ payload_ref=payload_ref,
44
+ ts_wall_ns=1000 + i,
45
+ )
46
+ )
47
+ priv, pub = generate_keypair()
48
+ bundle = build_bundle(
49
+ repo=repo, blob_store=blob_store, tenant_id=tenant_id, trace_id=trace_id,
50
+ from_seq=0, to_seq=n - 1, private_key=priv,
51
+ )
52
+ return bundle.as_dict, pub.hex()
53
+
54
+
55
+ def test_untampered_bundle_verifies(conninfo, tmp_path):
56
+ bundle, pubkey_hex = _make_bundle(conninfo, tmp_path)
57
+ result = verify_bundle(bundle, pubkey_hex)
58
+ assert result.ok, result.errors
59
+
60
+
61
+ def test_wrong_public_key_fails(conninfo, tmp_path):
62
+ bundle, _ = _make_bundle(conninfo, tmp_path)
63
+ _, wrong_pub = generate_keypair()
64
+ result = verify_bundle(bundle, wrong_pub.hex())
65
+ assert not result.ok
66
+ assert any("signature" in e for e in result.errors)
67
+
68
+
69
+ def test_tampered_event_is_detected_and_named(conninfo, tmp_path):
70
+ bundle, pubkey_hex = _make_bundle(conninfo, tmp_path, n=3)
71
+ tampered = copy.deepcopy(bundle)
72
+ tampered["events"][1]["input_hash"] = "0" * 64 # edit one stored event
73
+
74
+ result = verify_bundle(tampered, pubkey_hex)
75
+ assert not result.ok
76
+ # names the specific event, not just "something is wrong"
77
+ tampered_event_id = tampered["events"][1]["event_id"]
78
+ assert any(tampered_event_id in e for e in result.errors)
79
+ assert any("this_hash does not match" in e for e in result.errors)
80
+
81
+
82
+ def test_sophisticated_tamper_still_breaks_the_chain_link(conninfo, tmp_path):
83
+ # A naive edit (change a field, leave this_hash alone) is already caught
84
+ # by the per-event self-consistency check above. This simulates a more
85
+ # careful attacker who *also* recomputes event 0's this_hash to match
86
+ # its new (fake) content — self-consistent in isolation, but event 1's
87
+ # prev_hash still points at the ORIGINAL this_hash, so the link breaks.
88
+ from pramana_verify.verifier import _event_this_hash
89
+
90
+ bundle, pubkey_hex = _make_bundle(conninfo, tmp_path, n=3)
91
+ tampered = copy.deepcopy(bundle)
92
+ tampered["events"][0]["input_hash"] = "0" * 64
93
+ tampered["events"][0]["this_hash"] = _event_this_hash(
94
+ tampered["events"][0], tampered["events"][0]["prev_hash"]
95
+ )
96
+
97
+ result = verify_bundle(tampered, pubkey_hex)
98
+ assert not result.ok
99
+ assert not any("event #0" in e for e in result.errors) # event 0 is internally self-consistent
100
+ assert any("prev_hash does not match" in e for e in result.errors) # but the link to it is broken
101
+ assert any("merkle_root" in e for e in result.errors) # and the signed root no longer matches
102
+
103
+
104
+ def test_tampered_merkle_root_is_detected(conninfo, tmp_path):
105
+ bundle, pubkey_hex = _make_bundle(conninfo, tmp_path)
106
+ tampered = copy.deepcopy(bundle)
107
+ tampered["merkle_root"] = "f" * 64
108
+
109
+ result = verify_bundle(tampered, pubkey_hex)
110
+ assert not result.ok
111
+ assert any("merkle_root" in e for e in result.errors)
112
+ assert any("signature" in e for e in result.errors) # header changed too
113
+
114
+
115
+ def test_empty_bundle_is_rejected():
116
+ result = verify_bundle({"events": []}, "00" * 32)
117
+ assert not result.ok
118
+
119
+
120
+ def test_editing_a_recorded_prompt_or_response_is_detected(conninfo, tmp_path):
121
+ """The tamper an auditor cares most about, and the one that used to pass.
122
+
123
+ The hash chain covers each event's `payload_ref`, so an attacker can't
124
+ repoint an event at different content. But the prompt and response text
125
+ itself lives in `bundle["blobs"]`, and verify_bundle never read it — so
126
+ rewriting a recorded answer in place, leaving every hash and signature
127
+ untouched, verified completely clean.
128
+ """
129
+ bundle, pubkey_hex = _make_bundle(conninfo, tmp_path, n=3)
130
+ assert verify_bundle(bundle, pubkey_hex).ok
131
+
132
+ tampered = copy.deepcopy(bundle)
133
+ ref = tampered["events"][1]["payload_ref"]
134
+ assert tampered["blobs"][ref]["output"] == {"ok": True}
135
+ tampered["blobs"][ref]["output"] = {"ok": False, "refund": "not owed"}
136
+
137
+ result = verify_bundle(tampered, pubkey_hex)
138
+ assert not result.ok, "a rewritten recorded response must not verify"
139
+ assert any("tampered with" in e for e in result.errors)
140
+ assert any(ref[:12] in e for e in result.errors), "must name which payload"
141
+
142
+
143
+ def test_dropping_a_payload_is_detected(conninfo, tmp_path):
144
+ """Removing the content instead of editing it is the same attack: an
145
+ auditor is handed a bundle that verifies but shows them nothing."""
146
+ bundle, pubkey_hex = _make_bundle(conninfo, tmp_path, n=2)
147
+ tampered = copy.deepcopy(bundle)
148
+ tampered["blobs"] = {}
149
+
150
+ result = verify_bundle(tampered, pubkey_hex)
151
+ assert not result.ok
152
+ assert any("has no payload in this bundle" in e for e in result.errors)
153
+
154
+
155
+ def test_a_blob_nobody_references_is_still_hash_checked(conninfo, tmp_path):
156
+ """Content-addressing is checked per blob, so junk smuggled into `blobs`
157
+ under a made-up ref is caught even though no event points at it."""
158
+ bundle, pubkey_hex = _make_bundle(conninfo, tmp_path, n=2)
159
+ tampered = copy.deepcopy(bundle)
160
+ tampered["blobs"]["0" * 64] = {"input": "smuggled", "output": "smuggled"}
161
+
162
+ result = verify_bundle(tampered, pubkey_hex)
163
+ assert not result.ok
164
+ assert any("does not hash to its reference" in e for e in result.errors)