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.
- pramana_verify-0.0.1/.gitignore +15 -0
- pramana_verify-0.0.1/PKG-INFO +98 -0
- pramana_verify-0.0.1/README.md +78 -0
- pramana_verify-0.0.1/pyproject.toml +49 -0
- pramana_verify-0.0.1/src/pramana_verify/__init__.py +0 -0
- pramana_verify-0.0.1/src/pramana_verify/canonical.py +41 -0
- pramana_verify-0.0.1/src/pramana_verify/cli.py +48 -0
- pramana_verify-0.0.1/src/pramana_verify/merkle.py +32 -0
- pramana_verify-0.0.1/src/pramana_verify/verifier.py +114 -0
- pramana_verify-0.0.1/tests/conftest.py +18 -0
- pramana_verify-0.0.1/tests/test_matches_pramana_core.py +39 -0
- pramana_verify-0.0.1/tests/test_verify_cli_offline.py +164 -0
|
@@ -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)
|