pentr-verify 0.1.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.
- pentr_verify/__init__.py +3 -0
- pentr_verify/cli.py +130 -0
- pentr_verify/hash_chain.py +56 -0
- pentr_verify/locker.py +556 -0
- pentr_verify/manifest.py +56 -0
- pentr_verify/signing.py +132 -0
- pentr_verify-0.1.0.dist-info/METADATA +114 -0
- pentr_verify-0.1.0.dist-info/RECORD +11 -0
- pentr_verify-0.1.0.dist-info/WHEEL +4 -0
- pentr_verify-0.1.0.dist-info/entry_points.txt +2 -0
- pentr_verify-0.1.0.dist-info/licenses/LICENSE +21 -0
pentr_verify/__init__.py
ADDED
pentr_verify/cli.py
ADDED
|
@@ -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
|
pentr_verify/locker.py
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""Offline reader and verifier for an exported evidence locker.
|
|
2
|
+
|
|
3
|
+
PURE MODULE — imports nothing from server/, scheduler/, or SQLAlchemy. It must
|
|
4
|
+
run on an auditor's machine that has never seen the Pentr database. Every hash
|
|
5
|
+
here is recomputed with the exact same primitives that produced the seal
|
|
6
|
+
(pentr_verify.hash_chain, pentr_verify.manifest, pentr_verify.signing), which is what makes a PASS
|
|
7
|
+
mean something: the verifier cannot drift from the sealer.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import zipfile
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from pentr_verify import hash_chain, signing
|
|
19
|
+
from pentr_verify import manifest as manifest_mod
|
|
20
|
+
from pentr_verify.hash_chain import EntryInput
|
|
21
|
+
|
|
22
|
+
# The locker *packaging* format version, independent of pentr_verify.manifest.VERSION
|
|
23
|
+
# and pentr_verify.signing.SIGNATURE_VERSION even though all three read "pentr-locker-1"
|
|
24
|
+
# today. These are three separately-versioned concerns that happened to ship
|
|
25
|
+
# together in Phase 4: the manifest schema, the signature block schema, and the
|
|
26
|
+
# container format. Aliasing to either would mean bumping the schema version
|
|
27
|
+
# silently changes what the locker advertises, making them unable to move
|
|
28
|
+
# independently.
|
|
29
|
+
LOCKER_FORMAT = "pentr-locker-1"
|
|
30
|
+
|
|
31
|
+
MANIFEST_NAME = "manifest.json"
|
|
32
|
+
SIGNATURE_BLOCK_NAME = "signature_block.json"
|
|
33
|
+
SIGNATURE_NAME = "signature.bin"
|
|
34
|
+
PUBLIC_KEY_NAME = "public_key.pem"
|
|
35
|
+
SEAL_NAME = "seal.json"
|
|
36
|
+
README_NAME = "README.txt"
|
|
37
|
+
|
|
38
|
+
ROOT_FILES = frozenset({
|
|
39
|
+
MANIFEST_NAME, SIGNATURE_BLOCK_NAME, SIGNATURE_NAME,
|
|
40
|
+
PUBLIC_KEY_NAME, SEAL_NAME, README_NAME,
|
|
41
|
+
})
|
|
42
|
+
# `\Z`, not `$`: `$` also matches just before a trailing newline, so
|
|
43
|
+
# "artifacts/<64 hex>\n" would satisfy a guard written to reject it.
|
|
44
|
+
_ARTIFACT_MEMBER = re.compile(r"^artifacts/[0-9a-f]{64}\Z")
|
|
45
|
+
_PATH_SEPARATOR = re.compile(r"[\\/]")
|
|
46
|
+
|
|
47
|
+
# Every member is read whole into memory, so each read is bounded by the same
|
|
48
|
+
# per-artifact cap (10 MiB) the Pentr platform's artifact store enforces at seal
|
|
49
|
+
# time. No legitimate locker is affected: manifest.json, the signature block and
|
|
50
|
+
# every blob are all stored through that store, so none of them can exceed it. Without the cap,
|
|
51
|
+
# a signed locker can be recompressed with one member as a zip bomb -- the
|
|
52
|
+
# signature covers the manifest, not the compressed blob -- and pentr-verify is
|
|
53
|
+
# precisely the component designed to be pointed at files from untrusted parties.
|
|
54
|
+
MAX_MEMBER_BYTES = 10 * 1024 * 1024
|
|
55
|
+
|
|
56
|
+
# Fields verify_locker's stage 7 indexes directly (not via .get()) when
|
|
57
|
+
# rebuilding EntryInput/EntryResult. A manifest entry missing any of these is
|
|
58
|
+
# not tamper — it's a malformed locker — so it is rejected with
|
|
59
|
+
# LockerFormatError before any indexing happens, rather than surfacing as a
|
|
60
|
+
# raw KeyError.
|
|
61
|
+
_REQUIRED_ENTRY_FIELDS = ("seq", "kind", "phase", "size", "sha256", "created_at")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _escapes_locker_root(name: str) -> bool:
|
|
65
|
+
"""True if a member name is absolute or walks out of the locker root."""
|
|
66
|
+
if name.startswith("/") or name.startswith("\\"):
|
|
67
|
+
return True
|
|
68
|
+
return ".." in _PATH_SEPARATOR.split(name)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _validate_member_name(name: str) -> None:
|
|
72
|
+
"""Validate that a member name is in the locker allowlist.
|
|
73
|
+
|
|
74
|
+
Raises LockerFormatError if the name is not one of the root files or a
|
|
75
|
+
valid artifact path. This guards against path traversal attacks and
|
|
76
|
+
unexpected members.
|
|
77
|
+
"""
|
|
78
|
+
if name in ROOT_FILES or _ARTIFACT_MEMBER.match(name):
|
|
79
|
+
return
|
|
80
|
+
raise LockerFormatError(f"not a valid locker member: {name!r}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LockerFormatError(Exception):
|
|
84
|
+
"""The locker is unreadable or structurally malformed.
|
|
85
|
+
|
|
86
|
+
Distinct from a verification failure: this is "you gave me something that
|
|
87
|
+
is not a locker", which the CLI reports as exit 2, not as tampering.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
FAILURE_CODES = frozenset({
|
|
92
|
+
"unknown_format",
|
|
93
|
+
"unsupported_algorithm",
|
|
94
|
+
"manifest_binding_mismatch",
|
|
95
|
+
"key_id_mismatch",
|
|
96
|
+
"key_not_pinned_match",
|
|
97
|
+
"signature_invalid",
|
|
98
|
+
"genesis_mismatch",
|
|
99
|
+
"entry_hash_mismatch",
|
|
100
|
+
"chain_head_mismatch",
|
|
101
|
+
"blob_missing",
|
|
102
|
+
"blob_hash_mismatch",
|
|
103
|
+
"blob_size_mismatch",
|
|
104
|
+
"unlisted_blob",
|
|
105
|
+
"seal_claim_mismatch",
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class Failure:
|
|
111
|
+
code: str
|
|
112
|
+
message: str
|
|
113
|
+
subject: str | None = None
|
|
114
|
+
|
|
115
|
+
def __post_init__(self) -> None:
|
|
116
|
+
# FAILURE_CODES is the wire contract Phase 5's viewer and every --json
|
|
117
|
+
# consumer switch on. Enforcing it here means an invented or misspelled
|
|
118
|
+
# code fails loudly at construction instead of shipping silently.
|
|
119
|
+
assert self.code in FAILURE_CODES, f"unregistered failure code: {self.code!r}"
|
|
120
|
+
|
|
121
|
+
def to_dict(self) -> dict:
|
|
122
|
+
return {"code": self.code, "message": self.message, "subject": self.subject}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class EntryResult:
|
|
127
|
+
seq: int
|
|
128
|
+
sha256: str
|
|
129
|
+
kind: str
|
|
130
|
+
filename: str | None
|
|
131
|
+
ok: bool
|
|
132
|
+
reason: str | None = None
|
|
133
|
+
|
|
134
|
+
def to_dict(self) -> dict:
|
|
135
|
+
return {
|
|
136
|
+
"seq": self.seq, "sha256": self.sha256, "kind": self.kind,
|
|
137
|
+
"filename": self.filename, "ok": self.ok, "reason": self.reason,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class VerifyReport:
|
|
143
|
+
ok: bool
|
|
144
|
+
pinned: bool
|
|
145
|
+
key_id: str | None = None
|
|
146
|
+
run_id: str | None = None
|
|
147
|
+
chain_head: str | None = None
|
|
148
|
+
entry_count: int = 0
|
|
149
|
+
signed_at: str | None = None
|
|
150
|
+
entries: list[EntryResult] = field(default_factory=list)
|
|
151
|
+
failures: list[Failure] = field(default_factory=list)
|
|
152
|
+
|
|
153
|
+
def to_dict(self) -> dict:
|
|
154
|
+
return {
|
|
155
|
+
"ok": self.ok,
|
|
156
|
+
"pinned": self.pinned,
|
|
157
|
+
"key_id": self.key_id,
|
|
158
|
+
"run_id": self.run_id,
|
|
159
|
+
"chain_head": self.chain_head,
|
|
160
|
+
"entry_count": self.entry_count,
|
|
161
|
+
"signed_at": self.signed_at,
|
|
162
|
+
"entries": [e.to_dict() for e in self.entries],
|
|
163
|
+
"failures": [f.to_dict() for f in self.failures],
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Locker:
|
|
168
|
+
"""Read-only view over a locker directory or zip."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, source: str) -> None:
|
|
171
|
+
self.source = source
|
|
172
|
+
|
|
173
|
+
def read(self, name: str) -> bytes:
|
|
174
|
+
"""Read a locker member by name.
|
|
175
|
+
|
|
176
|
+
Validates that the name is in the allowlist (root files or artifacts)
|
|
177
|
+
before delegating to the backend, guarding against path traversal
|
|
178
|
+
attacks in both directory and zip lockers.
|
|
179
|
+
"""
|
|
180
|
+
_validate_member_name(name)
|
|
181
|
+
return self._read_member(name)
|
|
182
|
+
|
|
183
|
+
def _read_member(self, name: str) -> bytes:
|
|
184
|
+
"""Read a locker member (implemented by subclasses)."""
|
|
185
|
+
raise NotImplementedError
|
|
186
|
+
|
|
187
|
+
def list_artifact_names(self) -> set[str]:
|
|
188
|
+
raise NotImplementedError
|
|
189
|
+
|
|
190
|
+
def read_artifact(self, sha256: str) -> bytes:
|
|
191
|
+
return self.read(f"artifacts/{sha256}")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class _DirLocker(Locker):
|
|
195
|
+
def __init__(self, root: Path) -> None:
|
|
196
|
+
super().__init__(str(root))
|
|
197
|
+
self._root = root
|
|
198
|
+
|
|
199
|
+
def _read_member(self, name: str) -> bytes:
|
|
200
|
+
path = self._root / name
|
|
201
|
+
if not path.is_file():
|
|
202
|
+
raise LockerFormatError(f"locker is missing {name}")
|
|
203
|
+
size = path.stat().st_size
|
|
204
|
+
if size > MAX_MEMBER_BYTES:
|
|
205
|
+
raise LockerFormatError(
|
|
206
|
+
f"{name} is {size} bytes, above the {MAX_MEMBER_BYTES}-byte "
|
|
207
|
+
f"per-member cap; refusing to read it"
|
|
208
|
+
)
|
|
209
|
+
return path.read_bytes()
|
|
210
|
+
|
|
211
|
+
def list_artifact_names(self) -> set[str]:
|
|
212
|
+
# Deliberately does not filter by hex name. Stage 9 of verify_locker
|
|
213
|
+
# must be able to see a smuggled file (e.g., artifacts/secret.txt) in
|
|
214
|
+
# order to fail on it as unlisted_blob. If we filtered here, a smuggled
|
|
215
|
+
# file would be invisible and the locker would pass, silently breaking
|
|
216
|
+
# the strict both-way check.
|
|
217
|
+
adir = self._root / "artifacts"
|
|
218
|
+
if not adir.is_dir():
|
|
219
|
+
return set()
|
|
220
|
+
return {p.name for p in adir.iterdir() if p.is_file()}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class _ZipLocker(Locker):
|
|
224
|
+
def __init__(self, path: Path) -> None:
|
|
225
|
+
super().__init__(str(path))
|
|
226
|
+
try:
|
|
227
|
+
self._zf = zipfile.ZipFile(path)
|
|
228
|
+
except (zipfile.BadZipFile, OSError) as exc:
|
|
229
|
+
raise LockerFormatError(f"not a readable zip: {path}") from exc
|
|
230
|
+
self._names = {i.filename for i in self._zf.infolist() if not i.is_dir()}
|
|
231
|
+
# Validate every member up front. Nothing is extracted to disk, but an
|
|
232
|
+
# absolute or traversing path is a hostile archive, not a typo, and an
|
|
233
|
+
# unexpected *root* name means this is not a locker we understand.
|
|
234
|
+
#
|
|
235
|
+
# A flat artifacts/<anything> member is deliberately NOT rejected here,
|
|
236
|
+
# even when the name is not 64 hex: the directory reader can see such a
|
|
237
|
+
# file and stage 9 reports it as unlisted_blob (exit 1, "your evidence
|
|
238
|
+
# was tampered with"). Rejecting it at open time would make the same
|
|
239
|
+
# smuggled byte-for-byte tree exit 2 ("you gave me a broken path") in
|
|
240
|
+
# zip form — the more serious finding getting the less serious code, in
|
|
241
|
+
# the container auditors actually receive. read() still refuses the
|
|
242
|
+
# name (see _validate_member_name): stage 9 only lists it.
|
|
243
|
+
for name in self._names:
|
|
244
|
+
if _escapes_locker_root(name):
|
|
245
|
+
raise LockerFormatError(
|
|
246
|
+
f"hostile member path in locker zip: {name!r}")
|
|
247
|
+
if name in ROOT_FILES:
|
|
248
|
+
continue
|
|
249
|
+
rest = name[len("artifacts/"):] if name.startswith("artifacts/") else None
|
|
250
|
+
if rest is not None and rest and "/" not in rest:
|
|
251
|
+
continue
|
|
252
|
+
raise LockerFormatError(f"unexpected member in locker zip: {name!r}")
|
|
253
|
+
|
|
254
|
+
def _read_member(self, name: str) -> bytes:
|
|
255
|
+
if name not in self._names:
|
|
256
|
+
raise LockerFormatError(f"locker is missing {name}")
|
|
257
|
+
declared = self._zf.getinfo(name).file_size
|
|
258
|
+
if declared > MAX_MEMBER_BYTES:
|
|
259
|
+
raise LockerFormatError(
|
|
260
|
+
f"{name} declares {declared} bytes, above the "
|
|
261
|
+
f"{MAX_MEMBER_BYTES}-byte per-member cap; refusing to read it"
|
|
262
|
+
)
|
|
263
|
+
# The declared size lives in the central directory and is entirely
|
|
264
|
+
# attacker-controlled, so the header check above is not on its own a
|
|
265
|
+
# guard: read through a bounded stream as well and never ask for more
|
|
266
|
+
# than the cap plus one byte.
|
|
267
|
+
try:
|
|
268
|
+
with self._zf.open(name) as fh:
|
|
269
|
+
data = fh.read(MAX_MEMBER_BYTES + 1)
|
|
270
|
+
except (zipfile.BadZipFile, EOFError, OSError) as exc:
|
|
271
|
+
raise LockerFormatError(
|
|
272
|
+
f"{name} is not readable in this zip: {exc}") from exc
|
|
273
|
+
if len(data) > MAX_MEMBER_BYTES:
|
|
274
|
+
raise LockerFormatError(
|
|
275
|
+
f"{name} expands past the {MAX_MEMBER_BYTES}-byte per-member cap "
|
|
276
|
+
f"(it declared {declared} bytes); refusing to read it"
|
|
277
|
+
)
|
|
278
|
+
return data
|
|
279
|
+
|
|
280
|
+
def list_artifact_names(self) -> set[str]:
|
|
281
|
+
return {n.split("/", 1)[1] for n in self._names if n.startswith("artifacts/")}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def open_locker(path: str | Path) -> Locker:
|
|
285
|
+
p = Path(path)
|
|
286
|
+
if p.is_dir():
|
|
287
|
+
return _DirLocker(p)
|
|
288
|
+
if p.is_file():
|
|
289
|
+
return _ZipLocker(p)
|
|
290
|
+
raise LockerFormatError(f"no such locker: {p}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _load_public_key(pem_bytes: bytes) -> bytes:
|
|
294
|
+
"""PEM -> raw 32-byte Ed25519 public key."""
|
|
295
|
+
from cryptography.hazmat.primitives import serialization
|
|
296
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
key = serialization.load_pem_public_key(pem_bytes)
|
|
300
|
+
except Exception as exc:
|
|
301
|
+
raise LockerFormatError(f"unreadable {PUBLIC_KEY_NAME}: {exc}") from exc
|
|
302
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
303
|
+
raise LockerFormatError("only Ed25519 public keys are supported")
|
|
304
|
+
return key.public_bytes(
|
|
305
|
+
encoding=serialization.Encoding.Raw,
|
|
306
|
+
format=serialization.PublicFormat.Raw,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _parse_json(raw: bytes, name: str) -> dict:
|
|
311
|
+
try:
|
|
312
|
+
doc = json.loads(raw)
|
|
313
|
+
except json.JSONDecodeError as exc:
|
|
314
|
+
raise LockerFormatError(f"{name} is not valid JSON: {exc}") from exc
|
|
315
|
+
if not isinstance(doc, dict):
|
|
316
|
+
raise LockerFormatError(f"{name} must be a JSON object")
|
|
317
|
+
return doc
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def verify_locker(locker: Locker, *,
|
|
321
|
+
pinned_public_key: bytes | None = None) -> VerifyReport:
|
|
322
|
+
"""Verify an exported locker offline. Never raises on tamper — returns a report.
|
|
323
|
+
|
|
324
|
+
Raises LockerFormatError only when the input is not a readable locker at all
|
|
325
|
+
(missing files, bad JSON, unparseable key, or a manifest entry missing a
|
|
326
|
+
required field). That distinction is what lets the CLI say "you gave me a
|
|
327
|
+
broken path" (exit 2) rather than reporting tampering (exit 1) for an
|
|
328
|
+
operator error.
|
|
329
|
+
|
|
330
|
+
Stages 1-6 short-circuit: if the signature does not verify there is no value
|
|
331
|
+
in enumerating a hundred blobs, so the report names the signature failure and
|
|
332
|
+
stops. Stages 7-10 do the opposite and accumulate — once the locker is known
|
|
333
|
+
to be a well-formed, signed locker, every remaining check runs so an auditor
|
|
334
|
+
sees every problem (bad chain, missing blobs, smuggled blobs, a lying seal)
|
|
335
|
+
in one pass instead of fixing failures one at a time.
|
|
336
|
+
"""
|
|
337
|
+
report = VerifyReport(ok=False, pinned=pinned_public_key is not None)
|
|
338
|
+
|
|
339
|
+
# --- Stage 1: seal.json + format ---
|
|
340
|
+
seal = _parse_json(locker.read(SEAL_NAME), SEAL_NAME)
|
|
341
|
+
report.run_id = seal.get("run_id")
|
|
342
|
+
report.signed_at = seal.get("signed_at")
|
|
343
|
+
if seal.get("format") != LOCKER_FORMAT:
|
|
344
|
+
report.failures.append(Failure(
|
|
345
|
+
"unknown_format",
|
|
346
|
+
f"locker format {seal.get('format')!r} is not {LOCKER_FORMAT!r}",
|
|
347
|
+
SEAL_NAME,
|
|
348
|
+
))
|
|
349
|
+
return report
|
|
350
|
+
|
|
351
|
+
# --- Stage 2: manifest bytes, verbatim, hashed as read ---
|
|
352
|
+
manifest_bytes = locker.read(MANIFEST_NAME)
|
|
353
|
+
manifest_sha256_actual = hashlib.sha256(manifest_bytes).hexdigest()
|
|
354
|
+
manifest = _parse_json(manifest_bytes, MANIFEST_NAME)
|
|
355
|
+
report.chain_head = manifest.get("chain_head")
|
|
356
|
+
report.entry_count = len(manifest.get("entries") or [])
|
|
357
|
+
# Re-source the run id from the manifest. seal.json is an unsigned header,
|
|
358
|
+
# so the value read at stage 1 is attacker-controlled; stage 10 cross-checks
|
|
359
|
+
# it on a PASS, but a stage-3-to-6 short-circuit would otherwise print the
|
|
360
|
+
# attacker's run id back to the auditor.
|
|
361
|
+
run = manifest.get("run") or {}
|
|
362
|
+
if run.get("engagement_id") is not None:
|
|
363
|
+
report.run_id = str(run["engagement_id"])
|
|
364
|
+
|
|
365
|
+
if manifest.get("version") != manifest_mod.VERSION:
|
|
366
|
+
report.failures.append(Failure(
|
|
367
|
+
"unknown_format",
|
|
368
|
+
f"manifest version {manifest.get('version')!r} is not "
|
|
369
|
+
f"{manifest_mod.VERSION!r}",
|
|
370
|
+
MANIFEST_NAME,
|
|
371
|
+
))
|
|
372
|
+
return report
|
|
373
|
+
if manifest.get("algorithm") != manifest_mod.ALGORITHM:
|
|
374
|
+
# A future merkle-v1 locker must fail loudly on an old verifier rather
|
|
375
|
+
# than be checked with the wrong rules and reported as sound.
|
|
376
|
+
report.failures.append(Failure(
|
|
377
|
+
"unsupported_algorithm",
|
|
378
|
+
f"manifest algorithm {manifest.get('algorithm')!r} is not supported by "
|
|
379
|
+
f"this version of pentr-verify (expected {manifest_mod.ALGORITHM!r})",
|
|
380
|
+
MANIFEST_NAME,
|
|
381
|
+
))
|
|
382
|
+
return report
|
|
383
|
+
|
|
384
|
+
# --- Stage 3: the signature block must bind THESE manifest bytes ---
|
|
385
|
+
block_bytes = locker.read(SIGNATURE_BLOCK_NAME)
|
|
386
|
+
block = _parse_json(block_bytes, SIGNATURE_BLOCK_NAME)
|
|
387
|
+
report.key_id = block.get("key_id")
|
|
388
|
+
report.signed_at = block.get("signed_at") or report.signed_at
|
|
389
|
+
|
|
390
|
+
if (block.get("version") != signing.SIGNATURE_VERSION
|
|
391
|
+
or block.get("algorithm") != signing.SIGNATURE_ALGORITHM):
|
|
392
|
+
report.failures.append(Failure(
|
|
393
|
+
"unknown_format",
|
|
394
|
+
f"signature block is {block.get('version')!r}/{block.get('algorithm')!r}, "
|
|
395
|
+
f"expected {signing.SIGNATURE_VERSION!r}/{signing.SIGNATURE_ALGORITHM!r}",
|
|
396
|
+
SIGNATURE_BLOCK_NAME,
|
|
397
|
+
))
|
|
398
|
+
return report
|
|
399
|
+
if block.get("manifest_sha256") != manifest_sha256_actual:
|
|
400
|
+
report.failures.append(Failure(
|
|
401
|
+
"manifest_binding_mismatch",
|
|
402
|
+
f"signature covers manifest {block.get('manifest_sha256')} but this "
|
|
403
|
+
f"locker's manifest hashes to {manifest_sha256_actual}",
|
|
404
|
+
MANIFEST_NAME,
|
|
405
|
+
))
|
|
406
|
+
return report
|
|
407
|
+
if block.get("chain_head") != manifest.get("chain_head"):
|
|
408
|
+
report.failures.append(Failure(
|
|
409
|
+
"manifest_binding_mismatch",
|
|
410
|
+
f"signature covers chain head {block.get('chain_head')} but the manifest "
|
|
411
|
+
f"records {manifest.get('chain_head')}",
|
|
412
|
+
SIGNATURE_BLOCK_NAME,
|
|
413
|
+
))
|
|
414
|
+
return report
|
|
415
|
+
|
|
416
|
+
# --- Stage 4: key identity (content-derived, so it self-verifies) ---
|
|
417
|
+
public_raw = _load_public_key(locker.read(PUBLIC_KEY_NAME))
|
|
418
|
+
derived_key_id = signing.key_id_for(public_raw)
|
|
419
|
+
if derived_key_id != block.get("key_id"):
|
|
420
|
+
report.failures.append(Failure(
|
|
421
|
+
"key_id_mismatch",
|
|
422
|
+
f"{PUBLIC_KEY_NAME} has key id {derived_key_id} but the signature block "
|
|
423
|
+
f"names {block.get('key_id')}",
|
|
424
|
+
PUBLIC_KEY_NAME,
|
|
425
|
+
))
|
|
426
|
+
return report
|
|
427
|
+
if pinned_public_key is not None and pinned_public_key != public_raw:
|
|
428
|
+
report.failures.append(Failure(
|
|
429
|
+
"key_not_pinned_match",
|
|
430
|
+
f"locker is signed by key {derived_key_id} but you pinned "
|
|
431
|
+
f"{signing.key_id_for(pinned_public_key)}",
|
|
432
|
+
PUBLIC_KEY_NAME,
|
|
433
|
+
))
|
|
434
|
+
return report
|
|
435
|
+
|
|
436
|
+
# --- Stage 5: the signature itself ---
|
|
437
|
+
signature = locker.read(SIGNATURE_NAME)
|
|
438
|
+
if not signing.verify_signature(public_raw, block_bytes, signature):
|
|
439
|
+
report.failures.append(Failure(
|
|
440
|
+
"signature_invalid",
|
|
441
|
+
f"signature does not verify against key {derived_key_id}",
|
|
442
|
+
SIGNATURE_NAME,
|
|
443
|
+
))
|
|
444
|
+
return report
|
|
445
|
+
|
|
446
|
+
# --- Stage 6: genesis binds the run id (anti cross-run splicing) ---
|
|
447
|
+
expected_genesis = hash_chain.genesis(str(run.get("engagement_id")))
|
|
448
|
+
if expected_genesis != manifest.get("genesis"):
|
|
449
|
+
report.failures.append(Failure(
|
|
450
|
+
"genesis_mismatch",
|
|
451
|
+
f"manifest genesis {manifest.get('genesis')} does not match the genesis "
|
|
452
|
+
f"derived from run {run.get('engagement_id')}",
|
|
453
|
+
MANIFEST_NAME,
|
|
454
|
+
))
|
|
455
|
+
return report
|
|
456
|
+
|
|
457
|
+
# --- Stage 7: recompute the chain from the manifest's own entries ---
|
|
458
|
+
# Recompute, never trust. A valid signature already implies the manifest is
|
|
459
|
+
# unmodified, so this catches a *self-inconsistent* manifest — and, more to
|
|
460
|
+
# the point, it is the step that proves the shipped chain algorithm
|
|
461
|
+
# reproduces the sealed head, which is what an auditor is actually checking.
|
|
462
|
+
raw_entries = manifest.get("entries") or []
|
|
463
|
+
for idx, e in enumerate(raw_entries):
|
|
464
|
+
missing = [f for f in _REQUIRED_ENTRY_FIELDS if f not in e]
|
|
465
|
+
if missing:
|
|
466
|
+
raise LockerFormatError(
|
|
467
|
+
f"{MANIFEST_NAME} entry {idx} is missing required field(s): "
|
|
468
|
+
f"{', '.join(missing)}"
|
|
469
|
+
)
|
|
470
|
+
inputs = [
|
|
471
|
+
EntryInput(
|
|
472
|
+
seq=e["seq"], kind=e["kind"], phase=e["phase"], size=e["size"],
|
|
473
|
+
sha256=e["sha256"], control_ids=list(e.get("control_ids") or []),
|
|
474
|
+
finding_ids=list(e.get("finding_ids") or []),
|
|
475
|
+
filename=e.get("filename"), created_at=e["created_at"],
|
|
476
|
+
)
|
|
477
|
+
for e in raw_entries
|
|
478
|
+
]
|
|
479
|
+
recomputed, recomputed_head = hash_chain.compute_chain(
|
|
480
|
+
str(run.get("engagement_id")), inputs
|
|
481
|
+
)
|
|
482
|
+
for e, h in zip(raw_entries, recomputed, strict=True):
|
|
483
|
+
if e.get("entry_hash") != h:
|
|
484
|
+
report.failures.append(Failure(
|
|
485
|
+
"entry_hash_mismatch",
|
|
486
|
+
f"entry {e['seq']} records hash {e.get('entry_hash')} but recomputes "
|
|
487
|
+
f"to {h}",
|
|
488
|
+
e["sha256"],
|
|
489
|
+
))
|
|
490
|
+
if recomputed_head != manifest.get("chain_head"):
|
|
491
|
+
report.failures.append(Failure(
|
|
492
|
+
"chain_head_mismatch",
|
|
493
|
+
f"manifest records chain head {manifest.get('chain_head')} but the chain "
|
|
494
|
+
f"recomputes to {recomputed_head}",
|
|
495
|
+
MANIFEST_NAME,
|
|
496
|
+
))
|
|
497
|
+
|
|
498
|
+
# --- Stage 8: every entry's blob must be present and hash correctly ---
|
|
499
|
+
present = locker.list_artifact_names()
|
|
500
|
+
for e in raw_entries:
|
|
501
|
+
sha = e["sha256"]
|
|
502
|
+
result = EntryResult(seq=e["seq"], sha256=sha, kind=e["kind"],
|
|
503
|
+
filename=e.get("filename"), ok=True)
|
|
504
|
+
if sha not in present:
|
|
505
|
+
result.ok, result.reason = False, "blob missing from locker"
|
|
506
|
+
report.failures.append(Failure(
|
|
507
|
+
"blob_missing", f"artifacts/{sha} is listed in the manifest but "
|
|
508
|
+
f"absent from the locker", sha))
|
|
509
|
+
else:
|
|
510
|
+
data = locker.read_artifact(sha)
|
|
511
|
+
actual = hashlib.sha256(data).hexdigest()
|
|
512
|
+
if actual != sha:
|
|
513
|
+
result.ok, result.reason = False, "content does not match its hash"
|
|
514
|
+
report.failures.append(Failure(
|
|
515
|
+
"blob_hash_mismatch",
|
|
516
|
+
f"artifacts/{sha} hashes to {actual} — the evidence has been "
|
|
517
|
+
f"modified", sha))
|
|
518
|
+
elif len(data) != e["size"]:
|
|
519
|
+
result.ok, result.reason = False, "size does not match the manifest"
|
|
520
|
+
report.failures.append(Failure(
|
|
521
|
+
"blob_size_mismatch",
|
|
522
|
+
f"artifacts/{sha} is {len(data)} bytes, manifest says {e['size']}",
|
|
523
|
+
sha))
|
|
524
|
+
report.entries.append(result)
|
|
525
|
+
|
|
526
|
+
# --- Stage 9: nothing may be present that the manifest does not list ---
|
|
527
|
+
# "Evidence present but not sealed" is the smuggling case this locker exists
|
|
528
|
+
# to prevent, so an extra blob is a hard failure, not a warning.
|
|
529
|
+
listed = {e["sha256"] for e in raw_entries}
|
|
530
|
+
for extra in sorted(present - listed):
|
|
531
|
+
report.failures.append(Failure(
|
|
532
|
+
"unlisted_blob",
|
|
533
|
+
f"artifacts/{extra} is present in the locker but is not sealed in the "
|
|
534
|
+
f"manifest", extra))
|
|
535
|
+
|
|
536
|
+
# --- Stage 10: seal.json is an unsigned header; every claim is cross-checked ---
|
|
537
|
+
claims = [
|
|
538
|
+
("run_id", seal.get("run_id"), str(run.get("engagement_id"))),
|
|
539
|
+
("chain_head", seal.get("chain_head"), manifest.get("chain_head")),
|
|
540
|
+
("manifest_sha256", seal.get("manifest_sha256"), manifest_sha256_actual),
|
|
541
|
+
("entry_count", seal.get("entry_count"), len(raw_entries)),
|
|
542
|
+
("algorithm", seal.get("algorithm"), manifest.get("algorithm")),
|
|
543
|
+
("signature_algorithm", seal.get("signature_algorithm"),
|
|
544
|
+
block.get("algorithm")),
|
|
545
|
+
("signing_key_id", seal.get("signing_key_id"), block.get("key_id")),
|
|
546
|
+
("signed_at", seal.get("signed_at"), block.get("signed_at")),
|
|
547
|
+
]
|
|
548
|
+
for name, claimed, actual_value in claims:
|
|
549
|
+
if claimed != actual_value:
|
|
550
|
+
report.failures.append(Failure(
|
|
551
|
+
"seal_claim_mismatch",
|
|
552
|
+
f"{SEAL_NAME} claims {name}={claimed!r} but the signed material says "
|
|
553
|
+
f"{actual_value!r}", SEAL_NAME))
|
|
554
|
+
|
|
555
|
+
report.ok = not report.failures
|
|
556
|
+
return report
|
pentr_verify/manifest.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from pentr_verify.hash_chain import EntryInput, compute_chain, genesis
|
|
7
|
+
|
|
8
|
+
VERSION = "pentr-locker-1"
|
|
9
|
+
ALGORITHM = "sha256-linear-v1"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _canon_bytes(obj) -> bytes:
|
|
13
|
+
return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def scope_digest(scope_in: list[str], scope_out: list[str], rules: dict) -> str:
|
|
17
|
+
payload = _canon_bytes({"scope_in": scope_in, "scope_out": scope_out, "rules": rules})
|
|
18
|
+
return hashlib.sha256(payload).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_manifest(*, run_id: str, engagement_id: str, framework: str,
|
|
22
|
+
scope_in: list[str], scope_out: list[str], rules: dict,
|
|
23
|
+
started_at: str | None, ended_at: str | None,
|
|
24
|
+
entries: list[EntryInput]) -> tuple[bytes, str]:
|
|
25
|
+
entry_hashes, chain_head = compute_chain(run_id, entries)
|
|
26
|
+
manifest = {
|
|
27
|
+
"version": VERSION,
|
|
28
|
+
"algorithm": ALGORITHM,
|
|
29
|
+
"genesis": genesis(run_id),
|
|
30
|
+
"run": {
|
|
31
|
+
"engagement_id": engagement_id,
|
|
32
|
+
"framework": framework,
|
|
33
|
+
"scope_digest": scope_digest(scope_in, scope_out, rules),
|
|
34
|
+
"started_at": started_at,
|
|
35
|
+
"ended_at": ended_at,
|
|
36
|
+
},
|
|
37
|
+
"entries": [
|
|
38
|
+
{
|
|
39
|
+
"seq": e.seq,
|
|
40
|
+
"sha256": e.sha256,
|
|
41
|
+
"kind": e.kind,
|
|
42
|
+
"phase": e.phase,
|
|
43
|
+
"control_ids": sorted(e.control_ids),
|
|
44
|
+
"finding_ids": sorted(e.finding_ids),
|
|
45
|
+
"size": e.size,
|
|
46
|
+
"filename": e.filename,
|
|
47
|
+
"created_at": e.created_at,
|
|
48
|
+
"entry_hash": h,
|
|
49
|
+
}
|
|
50
|
+
for e, h in zip(entries, entry_hashes, strict=True)
|
|
51
|
+
],
|
|
52
|
+
"chain_head": chain_head,
|
|
53
|
+
"entry_count": len(entries),
|
|
54
|
+
}
|
|
55
|
+
manifest_bytes = _canon_bytes(manifest)
|
|
56
|
+
return manifest_bytes, hashlib.sha256(manifest_bytes).hexdigest()
|
pentr_verify/signing.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from cryptography.exceptions import InvalidSignature
|
|
8
|
+
from cryptography.hazmat.primitives import serialization
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
10
|
+
Ed25519PrivateKey,
|
|
11
|
+
Ed25519PublicKey,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
SIGNATURE_VERSION = "pentr-locker-1"
|
|
15
|
+
SIGNATURE_ALGORITHM = "ed25519-v1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvalidKeyError(Exception):
|
|
19
|
+
"""Raised when key material is unparseable, encrypted, or the wrong algorithm."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Signer(Protocol):
|
|
23
|
+
"""Produces a detached signature over canonical bytes.
|
|
24
|
+
|
|
25
|
+
SEAM — read this before believing any marketing copy: the only implementation
|
|
26
|
+
today is SoftwareSigner, which means the platform holds the org's private key
|
|
27
|
+
(encrypted at rest under PENTR_KEK). That is *software key custody*, not
|
|
28
|
+
"the platform never sees your private key". A KmsSigner or HsmSigner
|
|
29
|
+
implementing this same protocol is the honest upgrade path. It is
|
|
30
|
+
deliberately NOT implemented in Phase 3.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
key_id: str
|
|
34
|
+
algorithm: str
|
|
35
|
+
|
|
36
|
+
def sign(self, payload: bytes) -> bytes: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SoftwareSigner:
|
|
40
|
+
"""Ed25519 signer over an in-process raw private key."""
|
|
41
|
+
|
|
42
|
+
algorithm = SIGNATURE_ALGORITHM
|
|
43
|
+
|
|
44
|
+
def __init__(self, private_key_bytes: bytes, key_id: str) -> None:
|
|
45
|
+
self._key = Ed25519PrivateKey.from_private_bytes(private_key_bytes)
|
|
46
|
+
self.key_id = key_id
|
|
47
|
+
|
|
48
|
+
def sign(self, payload: bytes) -> bytes:
|
|
49
|
+
return self._key.sign(payload)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _raw_private(key: Ed25519PrivateKey) -> bytes:
|
|
53
|
+
return key.private_bytes(
|
|
54
|
+
encoding=serialization.Encoding.Raw,
|
|
55
|
+
format=serialization.PrivateFormat.Raw,
|
|
56
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def generate_private_key() -> bytes:
|
|
61
|
+
"""Fresh Ed25519 private key as 32 raw bytes."""
|
|
62
|
+
return _raw_private(Ed25519PrivateKey.generate())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def raw_public_key_from_private(private_key_bytes: bytes) -> bytes:
|
|
66
|
+
return Ed25519PrivateKey.from_private_bytes(private_key_bytes).public_key().public_bytes(
|
|
67
|
+
encoding=serialization.Encoding.Raw,
|
|
68
|
+
format=serialization.PublicFormat.Raw,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def private_key_from_pem(pem: str) -> bytes:
|
|
73
|
+
"""Parse an unencrypted PKCS#8 Ed25519 private key PEM into 32 raw bytes."""
|
|
74
|
+
try:
|
|
75
|
+
key = serialization.load_pem_private_key(pem.encode("utf-8"), password=None)
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
raise InvalidKeyError(f"unparseable or password-protected PEM: {exc}") from exc
|
|
78
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
79
|
+
raise InvalidKeyError("only Ed25519 private keys are supported")
|
|
80
|
+
return _raw_private(key)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def key_id_for(raw_public_key: bytes) -> str:
|
|
84
|
+
"""Derive the key id from the key material itself, so it is self-verifying."""
|
|
85
|
+
return hashlib.sha256(raw_public_key).hexdigest()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def public_key_pem(raw_public_key: bytes) -> str:
|
|
89
|
+
return Ed25519PublicKey.from_public_bytes(raw_public_key).public_bytes(
|
|
90
|
+
encoding=serialization.Encoding.PEM,
|
|
91
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
92
|
+
).decode("utf-8")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def signature_block(*, chain_head: str, manifest_sha256: str, key_id: str,
|
|
96
|
+
signed_at: str) -> bytes:
|
|
97
|
+
"""Canonical bytes that get signed.
|
|
98
|
+
|
|
99
|
+
Signing this block rather than the raw manifest binds the key identity and
|
|
100
|
+
the seal time into the signed material: a signature cannot be replayed onto
|
|
101
|
+
another manifest, and a mislabeled key_id is a detected failure rather than
|
|
102
|
+
a confusing one. Serialization rules match pentr_verify/manifest.py exactly.
|
|
103
|
+
"""
|
|
104
|
+
block = {
|
|
105
|
+
"version": SIGNATURE_VERSION,
|
|
106
|
+
"algorithm": SIGNATURE_ALGORITHM,
|
|
107
|
+
"chain_head": chain_head,
|
|
108
|
+
"manifest_sha256": manifest_sha256,
|
|
109
|
+
"key_id": key_id,
|
|
110
|
+
"signed_at": signed_at,
|
|
111
|
+
}
|
|
112
|
+
return json.dumps(
|
|
113
|
+
block, sort_keys=True, ensure_ascii=False, separators=(",", ":")
|
|
114
|
+
).encode("utf-8")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def verify_signature(public_key_bytes: bytes, block_bytes: bytes,
|
|
118
|
+
signature: bytes) -> bool:
|
|
119
|
+
"""Check a detached signature. Returns False on any failure; never raises.
|
|
120
|
+
|
|
121
|
+
Phase 4's `pentr verify` is the main consumer; it runs offline against an
|
|
122
|
+
exported locker using this exact function. `run_seals.signature` is
|
|
123
|
+
nullable by design (pre-signing seals have no signature block), so a
|
|
124
|
+
verifier reading an old seal WILL call this with `signature=None` — that
|
|
125
|
+
raises TypeError from the underlying library, not InvalidSignature, so it
|
|
126
|
+
must be caught here too.
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
Ed25519PublicKey.from_public_bytes(public_key_bytes).verify(signature, block_bytes)
|
|
130
|
+
return True
|
|
131
|
+
except (InvalidSignature, ValueError, TypeError):
|
|
132
|
+
return False
|
|
@@ -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,11 @@
|
|
|
1
|
+
pentr_verify/__init__.py,sha256=C52JK4KMjf9MnPZNJ_yYBD0f49c1qgMV5818fiDNO7Y,58
|
|
2
|
+
pentr_verify/cli.py,sha256=EQc99qfK8JfgNexryx1choz_SAPSNa5u0jnVUaJEfYY,4730
|
|
3
|
+
pentr_verify/hash_chain.py,sha256=bJ6AJL6FlDxs0J1bV1UcVT1QpEforq7CCY5KMDK2xv4,1491
|
|
4
|
+
pentr_verify/locker.py,sha256=9YJx0iKDUikaM-JObEGwZWlpG4DXoWDMT-maJQKsPSs,23282
|
|
5
|
+
pentr_verify/manifest.py,sha256=beKCoBUM6nyxlj9lWZCHWLA2hDcewqQTkmv0GWNyyUQ,1961
|
|
6
|
+
pentr_verify/signing.py,sha256=gDxzU9YolhGqN3N_mFsgekLD4dKbydqUGx2mX58WjW4,4739
|
|
7
|
+
pentr_verify-0.1.0.dist-info/METADATA,sha256=z2kmVbSd47DYv9SvKKcGYZn-o9CxwzOhbUITZ31GwP4,4243
|
|
8
|
+
pentr_verify-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
pentr_verify-0.1.0.dist-info/entry_points.txt,sha256=CSutsjOKA83Hn_LbKDCyZrxjiFQs20d6IKpKap_wWtU,55
|
|
10
|
+
pentr_verify-0.1.0.dist-info/licenses/LICENSE,sha256=KhgYMYuPp9Dmuq3M9jU_vos6vlLQzmXqvDNa40UiFeY,1064
|
|
11
|
+
pentr_verify-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|