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.
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.1.0"
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
@@ -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()
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pentr-verify = pentr_verify.cli:main
@@ -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.