pentr-verify 0.1.0__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,39 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: astral-sh/setup-uv@v6
13
+ - name: Check the tag matches the package version
14
+ run: |
15
+ version=$(uv version --short)
16
+ test "${GITHUB_REF_NAME}" = "v${version}" || {
17
+ echo "release tag ${GITHUB_REF_NAME} does not match pyproject version ${version}"; exit 1; }
18
+ - run: uv run ruff check .
19
+ - run: uv run pytest -q
20
+ - run: uv build
21
+ - uses: actions/upload-artifact@v4
22
+ with:
23
+ name: dist
24
+ path: dist/
25
+
26
+ publish:
27
+ needs: build
28
+ runs-on: ubuntu-latest
29
+ environment:
30
+ name: pypi
31
+ url: https://pypi.org/p/pentr-verify
32
+ permissions:
33
+ id-token: write
34
+ steps:
35
+ - uses: actions/download-artifact@v4
36
+ with:
37
+ name: dist
38
+ path: dist/
39
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .coverage
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PentrAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.5
2
+ Name: pentr-verify
3
+ Version: 0.1.0
4
+ Summary: Offline verifier for Pentr evidence lockers
5
+ Project-URL: Homepage, https://github.com/PentrAI/pentr-verify
6
+ Author: PentrAI
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Security :: Cryptography
12
+ Requires-Python: >=3.12
13
+ Requires-Dist: cryptography>=42
14
+ Description-Content-Type: text/markdown
15
+
16
+ # pentr-verify
17
+
18
+ Offline verifier for [Pentr](https://github.com/PentrAI) evidence lockers.
19
+
20
+ When Pentr finishes a penetration test, it seals all the evidence it captured
21
+ (command logs, HTTP requests and responses, screenshots) into a hash chain and
22
+ signs the result with the organization's Ed25519 key. The exported locker is a
23
+ directory or `.zip` that anyone can check with this tool. You don't need
24
+ network access, a database or a Pentr account.
25
+
26
+ The platform seals lockers with this same code, so a PASS here uses exactly the
27
+ checks the platform's own seal is built on.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pipx install git+https://github.com/PentrAI/pentr-verify
33
+ # or
34
+ uv tool install git+https://github.com/PentrAI/pentr-verify
35
+ ```
36
+
37
+ The only runtime dependency is `cryptography`. Python 3.12 or newer is required.
38
+
39
+ ## Usage
40
+
41
+ ```bash
42
+ pentr-verify locker.zip # verify against the key inside the locker
43
+ pentr-verify locker.zip --key org-key.pem # pin the signer's public key
44
+ pentr-verify locker/ --json # machine-readable report
45
+ pentr-verify locker.zip --quiet # skip the per-artifact listing
46
+ ```
47
+
48
+ `--key` accepts a PEM public key, 64 hex characters, or 32 raw bytes.
49
+
50
+ | Exit code | Meaning |
51
+ |-----------|---------|
52
+ | `0` | PASS: the locker verified |
53
+ | `1` | FAIL: the locker did not verify (tampered, incomplete, or signed by a different key) |
54
+ | `2` | Usage error: the input is not a readable locker, or `--key` is unreadable |
55
+
56
+ ### Pinned and unpinned verification
57
+
58
+ Without `--key`, the locker is checked against the public key it carries. That
59
+ proves the locker is internally consistent and has not been modified since it
60
+ was signed. It does **not** prove who signed it. To prove provenance, get the
61
+ organization's public key through a channel you trust and pass it with `--key`.
62
+
63
+ ## Locker layout
64
+
65
+ ```
66
+ manifest.json # canonical run manifest: every entry, its hash and chain hash
67
+ signature_block.json # the signed bytes: chain head, manifest hash, key id, signed_at
68
+ signature.bin # detached Ed25519 signature over signature_block.json
69
+ public_key.pem # the signer's public key
70
+ seal.json # run id, counts and claims, cross-checked against the above
71
+ README.txt # human instructions
72
+ artifacts/<sha256> # one file per manifest entry, named by its SHA-256
73
+ ```
74
+
75
+ ## What is checked
76
+
77
+ 1. `seal.json` names a known locker format and algorithms.
78
+ 2. The signature block binds the exact bytes of `manifest.json`.
79
+ 3. The key id is the SHA-256 of the public key, and matches the pinned key when `--key` is given.
80
+ 4. The Ed25519 signature over the signature block verifies.
81
+ 5. The chain's genesis matches the run id.
82
+ 6. Every entry hash and the chain head recompute from the entry metadata.
83
+ 7. Every artifact exists, has the listed size, and hashes to its name.
84
+ 8. No artifact is present that the manifest does not list.
85
+ 9. Every claim in `seal.json` matches the signed manifest.
86
+
87
+ Every failure is reported with a stable code (`signature_invalid`,
88
+ `chain_head_mismatch`, `blob_hash_mismatch`, `unlisted_blob`, ...). The full set
89
+ is `FAILURE_CODES` in `pentr_verify/locker.py`.
90
+
91
+ ## Limits
92
+
93
+ - **Key custody.** The Pentr platform holds each organization's private key,
94
+ encrypted at rest.
95
+ - **Timestamp.** `signed_at` is recorded by the platform's own clock.
96
+ - **Revocation check.** A locker signed with a key that was later retired
97
+ still verifies.
98
+
99
+ ## Releasing
100
+
101
+ Publishing a GitHub release builds the package, runs the tests and uploads it
102
+ to PyPI (`.github/workflows/release.yml`, PyPI trusted publishing). The release
103
+ tag must match the version in `pyproject.toml`, e.g. `v0.1.0`.
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ uv run pytest
109
+ uv run ruff check .
110
+ ```
111
+
112
+ ## License
113
+
114
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,99 @@
1
+ # pentr-verify
2
+
3
+ Offline verifier for [Pentr](https://github.com/PentrAI) evidence lockers.
4
+
5
+ When Pentr finishes a penetration test, it seals all the evidence it captured
6
+ (command logs, HTTP requests and responses, screenshots) into a hash chain and
7
+ signs the result with the organization's Ed25519 key. The exported locker is a
8
+ directory or `.zip` that anyone can check with this tool. You don't need
9
+ network access, a database or a Pentr account.
10
+
11
+ The platform seals lockers with this same code, so a PASS here uses exactly the
12
+ checks the platform's own seal is built on.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pipx install git+https://github.com/PentrAI/pentr-verify
18
+ # or
19
+ uv tool install git+https://github.com/PentrAI/pentr-verify
20
+ ```
21
+
22
+ The only runtime dependency is `cryptography`. Python 3.12 or newer is required.
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ pentr-verify locker.zip # verify against the key inside the locker
28
+ pentr-verify locker.zip --key org-key.pem # pin the signer's public key
29
+ pentr-verify locker/ --json # machine-readable report
30
+ pentr-verify locker.zip --quiet # skip the per-artifact listing
31
+ ```
32
+
33
+ `--key` accepts a PEM public key, 64 hex characters, or 32 raw bytes.
34
+
35
+ | Exit code | Meaning |
36
+ |-----------|---------|
37
+ | `0` | PASS: the locker verified |
38
+ | `1` | FAIL: the locker did not verify (tampered, incomplete, or signed by a different key) |
39
+ | `2` | Usage error: the input is not a readable locker, or `--key` is unreadable |
40
+
41
+ ### Pinned and unpinned verification
42
+
43
+ Without `--key`, the locker is checked against the public key it carries. That
44
+ proves the locker is internally consistent and has not been modified since it
45
+ was signed. It does **not** prove who signed it. To prove provenance, get the
46
+ organization's public key through a channel you trust and pass it with `--key`.
47
+
48
+ ## Locker layout
49
+
50
+ ```
51
+ manifest.json # canonical run manifest: every entry, its hash and chain hash
52
+ signature_block.json # the signed bytes: chain head, manifest hash, key id, signed_at
53
+ signature.bin # detached Ed25519 signature over signature_block.json
54
+ public_key.pem # the signer's public key
55
+ seal.json # run id, counts and claims, cross-checked against the above
56
+ README.txt # human instructions
57
+ artifacts/<sha256> # one file per manifest entry, named by its SHA-256
58
+ ```
59
+
60
+ ## What is checked
61
+
62
+ 1. `seal.json` names a known locker format and algorithms.
63
+ 2. The signature block binds the exact bytes of `manifest.json`.
64
+ 3. The key id is the SHA-256 of the public key, and matches the pinned key when `--key` is given.
65
+ 4. The Ed25519 signature over the signature block verifies.
66
+ 5. The chain's genesis matches the run id.
67
+ 6. Every entry hash and the chain head recompute from the entry metadata.
68
+ 7. Every artifact exists, has the listed size, and hashes to its name.
69
+ 8. No artifact is present that the manifest does not list.
70
+ 9. Every claim in `seal.json` matches the signed manifest.
71
+
72
+ Every failure is reported with a stable code (`signature_invalid`,
73
+ `chain_head_mismatch`, `blob_hash_mismatch`, `unlisted_blob`, ...). The full set
74
+ is `FAILURE_CODES` in `pentr_verify/locker.py`.
75
+
76
+ ## Limits
77
+
78
+ - **Key custody.** The Pentr platform holds each organization's private key,
79
+ encrypted at rest.
80
+ - **Timestamp.** `signed_at` is recorded by the platform's own clock.
81
+ - **Revocation check.** A locker signed with a key that was later retired
82
+ still verifies.
83
+
84
+ ## Releasing
85
+
86
+ Publishing a GitHub release builds the package, runs the tests and uploads it
87
+ to PyPI (`.github/workflows/release.yml`, PyPI trusted publishing). The release
88
+ tag must match the version in `pyproject.toml`, e.g. `v0.1.0`.
89
+
90
+ ## Development
91
+
92
+ ```bash
93
+ uv run pytest
94
+ uv run ruff check .
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,130 @@
1
+ """`pentr-verify` — verify an exported Pentr evidence locker, offline.
2
+
3
+ This command needs no network, no database, and no Pentr credentials. It is the
4
+ whole point of the evidence locker: an auditor who has never heard of us can
5
+ check the evidence with the same code that sealed it.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from pentr_verify.locker import LockerFormatError, VerifyReport, open_locker, verify_locker
15
+
16
+ EXIT_PASS = 0
17
+ EXIT_FAIL = 1
18
+ EXIT_USAGE = 2
19
+
20
+ _UNPINNED_WARNING = """
21
+ UNPINNED: this locker was verified against the public key inside it. That proves
22
+ the locker is internally consistent and unmodified since it was signed — it does
23
+ NOT prove who signed it. To prove provenance, obtain the organization's public
24
+ key through a channel you trust and re-run with --key <file>.
25
+ """.strip()
26
+
27
+
28
+ def load_public_key_file(path: str) -> bytes:
29
+ """Read a public key as PEM, 64 hex chars, or 32 raw bytes -> raw 32 bytes."""
30
+ raw = Path(path).read_bytes()
31
+ text = raw.strip()
32
+ if text.startswith(b"-----BEGIN"):
33
+ from cryptography.hazmat.primitives import serialization
34
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
35
+
36
+ key = serialization.load_pem_public_key(text)
37
+ if not isinstance(key, Ed25519PublicKey):
38
+ raise ValueError("only Ed25519 public keys are supported")
39
+ return key.public_bytes(
40
+ encoding=serialization.Encoding.Raw,
41
+ format=serialization.PublicFormat.Raw,
42
+ )
43
+ if len(text) == 64:
44
+ try:
45
+ return bytes.fromhex(text.decode("ascii"))
46
+ except (ValueError, UnicodeDecodeError):
47
+ pass
48
+ if len(raw) == 32:
49
+ return raw
50
+ raise ValueError(f"{path} is not an Ed25519 public key (PEM, 64 hex, or 32 bytes)")
51
+
52
+
53
+ def render_text(report: VerifyReport, quiet: bool = False) -> str:
54
+ lines: list[str] = []
55
+ if not quiet:
56
+ for e in report.entries:
57
+ status = "OK " if e.ok else "BAD "
58
+ name = e.filename or "-"
59
+ lines.append(f"{status} {e.seq:>4} {e.kind:<16} {e.sha256[:12]} {name}")
60
+ if report.entries:
61
+ lines.append("")
62
+
63
+ if report.ok:
64
+ lines.append("PASS — evidence locker verified")
65
+ else:
66
+ lines.append("FAIL — evidence locker did NOT verify")
67
+ for f in report.failures:
68
+ subject = f" [{f.subject}]" if f.subject else ""
69
+ lines.append(f" FAIL {f.code}{subject}: {f.message}")
70
+
71
+ lines.append("")
72
+ lines.append(
73
+ f" run {report.run_id}\n"
74
+ f" artifacts {report.entry_count}\n"
75
+ f" chain head {report.chain_head}\n"
76
+ f" signed by {report.key_id}\n"
77
+ f" signed at {report.signed_at}"
78
+ )
79
+ if report.ok and not report.pinned:
80
+ lines.append("")
81
+ lines.append(_UNPINNED_WARNING)
82
+ return "\n".join(lines)
83
+
84
+
85
+ def run(argv: list[str] | None = None) -> int:
86
+ parser = argparse.ArgumentParser(
87
+ prog="pentr-verify",
88
+ description="Verify an exported Pentr evidence locker offline.",
89
+ )
90
+ parser.add_argument("locker", help="Path to a locker directory or .zip")
91
+ parser.add_argument("--key", default=None,
92
+ help="Public key to pin (PEM, hex, or raw). Without it, "
93
+ "verification proves consistency but not provenance.")
94
+ parser.add_argument("--json", action="store_true", dest="as_json",
95
+ help="Emit the verification report as JSON")
96
+ parser.add_argument("--quiet", action="store_true",
97
+ help="Suppress the per-artifact listing")
98
+ args = parser.parse_args(argv)
99
+
100
+ pinned: bytes | None = None
101
+ if args.key is not None:
102
+ try:
103
+ pinned = load_public_key_file(args.key)
104
+ except (OSError, ValueError) as exc:
105
+ print(f"pentr-verify: cannot read --key: {exc}", file=sys.stderr)
106
+ return EXIT_USAGE
107
+
108
+ try:
109
+ locker = open_locker(args.locker)
110
+ report = verify_locker(locker, pinned_public_key=pinned)
111
+ except LockerFormatError as exc:
112
+ # Not a verification failure: the input is not a locker at all. Reporting
113
+ # this as exit 1 would tell an auditor their evidence was tampered with
114
+ # when they actually just mistyped a path.
115
+ print(f"pentr-verify: {exc}", file=sys.stderr)
116
+ return EXIT_USAGE
117
+
118
+ if args.as_json:
119
+ print(json.dumps(report.to_dict(), indent=2))
120
+ else:
121
+ print(render_text(report, quiet=args.quiet))
122
+ return EXIT_PASS if report.ok else EXIT_FAIL
123
+
124
+
125
+ def main() -> None:
126
+ sys.exit(run())
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass
6
+
7
+ GENESIS_PREFIX = "pentr-evidence-locker-v1|"
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class EntryInput:
12
+ seq: int
13
+ kind: str
14
+ phase: str
15
+ size: int
16
+ sha256: str
17
+ control_ids: list[str]
18
+ finding_ids: list[str]
19
+ filename: str | None
20
+ created_at: str
21
+
22
+
23
+ def _canon(obj) -> str:
24
+ return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
25
+
26
+
27
+ def genesis(run_id: str) -> str:
28
+ return hashlib.sha256((GENESIS_PREFIX + run_id).encode("utf-8")).hexdigest()
29
+
30
+
31
+ def entry_metadata(entry: EntryInput) -> str:
32
+ return _canon({
33
+ "seq": entry.seq,
34
+ "kind": entry.kind,
35
+ "phase": entry.phase,
36
+ "size": entry.size,
37
+ "control_ids": sorted(entry.control_ids),
38
+ "finding_ids": sorted(entry.finding_ids),
39
+ "filename": entry.filename,
40
+ "created_at": entry.created_at,
41
+ })
42
+
43
+
44
+ def entry_hash(prev_hex: str, artifact_sha256: str, metadata_json: str) -> str:
45
+ payload = prev_hex + artifact_sha256 + metadata_json
46
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
47
+
48
+
49
+ def compute_chain(run_id: str, entries: list[EntryInput]) -> tuple[list[str], str]:
50
+ prev = genesis(run_id)
51
+ hashes: list[str] = []
52
+ for entry in entries:
53
+ prev = entry_hash(prev, entry.sha256, entry_metadata(entry))
54
+ hashes.append(prev)
55
+ head = hashes[-1] if hashes else genesis(run_id)
56
+ return hashes, head