Verifying a locker
Checking a Pentr evidence locker yourself, offline, with no account.
Someone has sent you a Pentr evidence locker: a zip file, or a link that offers one for download. A locker is the sealed record of one penetration test run — the commands that were issued, the HTTP exchanges that were made, the screenshots that were taken — hashed as they were produced and signed once the run finished.
This page is how you check that record on your own machine. The check itself needs no Pentr account and no network access. Two things do have to come from outside the archive: the verification tool and the organization's public key. The tool comes from its public repository. The public key comes from the organization whose evidence you are checking, which limits what a pass establishes. Both are covered below.
What is inside a locker¶
A locker is a zip archive, or a directory you have unpacked one into. It holds these members:
| Member | What it is |
|---|---|
manifest.json |
the sealed, ordered record of every artifact in the run |
signature_block.json |
the exact bytes that were signed |
signature.bin |
the raw Ed25519 signature over those bytes |
public_key.pem |
the public half of the signing key |
seal.json |
an unsigned summary header |
README.txt |
notes for you, not covered by the signature |
artifacts/<sha256> |
one file per sealed artifact, named by its SHA-256 |
A zip carrying an unexpected member at its root is rejected as not a locker before any checking starts.
Each artifact is a command log (the command, its stdout and stderr, its exit
code and its timing), a request/response pair, a screenshot, or other — the
kind used for a free-form payload or file the testing agent attached to a
finding. A finding is one issue the run reported; its write-up, severity and
remediation advice live in the report rather than in the locker, and manifest
entries refer to findings by bare identifier. The kind column in the output
below names which one you are looking at. The locker holds captured evidence. It
does not contain the report that was written from that evidence.
Obtaining the verifier¶
pentr-verify is a console script. It needs Python 3.12 or newer and, beyond the
standard library, cryptography.
It is open source under the MIT licence. The source is at github.com/PentrAI/pentr-verify; obtain it from the repository rather than from the party that sent you the locker.
Because the source is public, you can read what the verifier does before you run it. The checks are also standard — SHA-256 digests and an Ed25519 signature over a canonical JSON block — so anyone who would rather not run Pentr's code can reimplement them.
The verifier reads only the files you name on the command line. Its command-line
module imports the standard library and one other module, core.locker, which in
turn imports the standard library and four pure modules of the same
codebase: core.hash_chain, core.manifest, core.signing and
core.artifact_store. cryptography enters one level further down, through
core.signing, which is where the Ed25519 check lives. No network client, no
database driver and no credential appears anywhere on that transitive import
path, which is what allows the tool to run on a machine that has never seen the
Pentr platform.
What an independent implementation has to reproduce¶
Three details decide whether a reimplementation arrives at the same hashes.
Every JSON string that goes into the chain is serialised with the same three
settings — in Python terms sort_keys=True, ensure_ascii=False and
separators=(",", ":"). Keys in sorted order, non-ASCII characters written as
UTF-8 rather than \uXXXX escapes, and no space after a comma or a colon. The
signature block is built the same way, and both it and the manifest are checked
as the bytes they ship as, so pretty-printing either file breaks the seal.
Each entry's hash is the SHA-256 of three strings joined in this order: the previous entry's hash as hex, the artifact's own hex SHA-256, and the entry's metadata as a canonical JSON string.
entry_hash = sha256(prev_hash_hex + artifact_sha256 + metadata_json)
The metadata object carries eight keys and nothing else: seq, kind, phase,
size, control_ids, finding_ids, filename and created_at. Both
identifier lists are sorted before they go in, and an entry that omits one is
read as an empty list. The artifact's own hash sits beside the object rather
than inside it, and the entry_hash the manifest records against each entry is
not part of the hashed material either.
The chain starts from a value derived from the run:
genesis = sha256("pentr-evidence-locker-v1|" + run_id)
run_id is the manifest's run.engagement_id, which is the field the verifier
itself re-reads, and the genesis value is the first entry's prev_hash_hex. A
manifest with no entries has a chain head equal to it. Otherwise the chain head
is the hash of the last entry, and signature_block.json binds that head
together with the SHA-256 of the manifest bytes.
Running it¶
pentr-verify "pentr-locker-<run-id>.zip" --key org-public-key.pem
The first argument is the path to the zip, or to a directory holding an unpacked locker. Both are read the same way.
Pinning the public key¶
With --key, the key you supply is compared against the key bundled in the
locker, and verification stops with key_not_pinned_match if they differ. A
PASS then means the locker was signed by the key you pinned, and that not one
byte of the sealed evidence has changed since.
Without --key, verification falls back to the public_key.pem inside the
archive. Every other check runs as before, but the key it checks against is the
one the archive itself supplied. The tool says so in its own output:
UNPINNED: this locker was verified against the public key inside it. That proves
the locker is internally consistent and unmodified since it was signed — it does
NOT prove who signed it. To prove provenance, obtain the organization's public
key through a channel you trust and re-run with --key <file>.
Anyone able to modify a locker can re-sign it with a key of their own and place that key in the archive, and the result verifies unpinned. An unpinned PASS is a statement about the archive's internal consistency and nothing more.
Getting the real public key
Obtain the organization's public key from the organization, through a channel where you can tell who you are talking to. Not from the zip, which is the artifact under examination. Not from the same message that carried the locker or the link, which an attacker who controlled that message controlled too.
--key accepts three encodings of an Ed25519 public key:
- a PEM file beginning
-----BEGIN, which must hold an Ed25519 key; - 64 hexadecimal characters;
- exactly 32 raw bytes.
Leading and trailing whitespace is stripped before the PEM and hex forms are tested, so a trailing newline does no harm in either. The raw form is measured on the file as it is, so a raw key file has to be exactly 32 bytes long.
You can also pin by comparing identifiers rather than files. The signed by
line in the output is the SHA-256 of the raw 32-byte public key, and on a PASS
the tool has already confirmed that the key in the archive hashes to that value.
Comparing that hex string against one the organization gives you independently
establishes the same thing --key does, assuming SHA-256 is sound.
Reading the output¶
A run that verifies, with no key pinned, looks like this:
OK 0 command_log f869ee4c0b89 -
OK 1 req_resp 2482ffa1be00 -
OK 2 screenshot 075f8e3f22fc screenshot.png
PASS — evidence locker verified
run 3f9a1c2e-7b44-4d21-9a6e-0c5b8e2d14f7
artifacts 3
chain head af08f99ccd6af021f46396c82a2cd859c6296f32b28af9f2291e6ae7e0ae0bab
signed by 5f79492518ca52f60086291848a46ef8c9a852f7cb0394509b0fa552541a3d69
signed at 2026-08-26T01:04:11.229107+00:00
UNPINNED: this locker was verified against the public key inside it. That proves
the locker is internally consistent and unmodified since it was signed — it does
NOT prove who signed it. To prove provenance, obtain the organization's public
key through a channel you trust and re-run with --key <file>.
A pinned run prints the same report without that closing block. In text mode its
presence or absence is the only difference between an unpinned PASS and a pinned
one, so read for it rather than for the word PASS. --json states it as a field
instead.
Each artifact line carries a status, the artifact's position in the run, its
kind, the first twelve hex characters of its SHA-256, and its filename where it
has one. --quiet suppresses those lines and leaves the verdict and the summary.
In the summary block, chain head is the hash of the last entry in the run's
chain. Every entry's hash covers the previous entry's hash, the artifact's own
hash and the entry's metadata, so the chain head commits to every artifact in
the run and to their order. signed by is the key identifier described above.
signed at is the timestamp inside the signed bytes.
A failure names what broke:
BAD 0 command_log f869ee4c0b89 -
OK 1 req_resp 2482ffa1be00 -
OK 2 screenshot 075f8e3f22fc screenshot.png
FAIL — evidence locker did NOT verify
FAIL blob_hash_mismatch [f869ee4c0b89…]: artifacts/f869ee4c0b89… hashes to
d121be310300… — the evidence has been modified
run 3f9a1c2e-7b44-4d21-9a6e-0c5b8e2d14f7
artifacts 3
chain head af08f99ccd6af021f46396c82a2cd859c6296f32b28af9f2291e6ae7e0ae0bab
signed by 5f79492518ca52f60086291848a46ef8c9a852f7cb0394509b0fa552541a3d69
signed at 2026-08-26T01:04:11.229107+00:00
The summary block is printed whether the locker verifies or not, so the values in it are not a sign that anything passed.
What each failure code means¶
| Code | What failed |
|---|---|
unknown_format |
the locker, manifest or signature block declares a version this verifier does not implement |
unsupported_algorithm |
the manifest names a chain algorithm this verifier does not implement |
manifest_binding_mismatch |
the signature covers a different manifest, or a different chain head, than the one shipped |
key_id_mismatch |
the bundled public key is not the key the signature block names |
key_not_pinned_match |
the locker is signed by a key other than the one you pinned |
signature_invalid |
the signature does not verify against the public key |
genesis_mismatch |
the chain's starting value does not derive from the run id in the manifest |
entry_hash_mismatch |
an entry's recorded hash is not what recomputing the chain produces |
chain_head_mismatch |
the recomputed chain does not end at the sealed head |
blob_missing |
the manifest lists an artifact that is not in the archive |
blob_hash_mismatch |
an artifact's bytes do not hash to the name it is stored under |
blob_size_mismatch |
an artifact's length disagrees with the manifest |
unlisted_blob |
the archive carries an artifact the manifest does not seal |
seal_claim_mismatch |
seal.json states something the signed material contradicts |
The order of checking matters when you read a report with one failure in it. Format, manifest binding, key identity, the signature itself and the chain's starting value are checked first, and the run stops at the first of those that fails — once the signature is known bad there is nothing to learn from enumerating artifacts. From the chain recomputation onwards the checks accumulate instead, so a report that reaches that stage names every remaining problem in one pass.
seal.json is an unsigned header, so a modified locker can say anything in it.
Nine of its eleven fields are checked against something the header does not
control. Eight are checked against material that is signed — the run id, chain
head, manifest hash, artifact count, both algorithms, the key id and the signing
time — each reported as seal_claim_mismatch on disagreement. The ninth,
format, is compared at the start of verification against the version string
compiled into the verifier itself; a locker declaring any other format fails as
unknown_format before the signed material is read at all. The other two,
sealed_at and exported_at, are checked against nothing. A locker whose
sealed_at and exported_at have been rewritten still verifies, pinned, as a
PASS.
Exit codes¶
| Code | Constant | Meaning |
|---|---|---|
| 0 | EXIT_PASS |
the locker verified |
| 1 | EXIT_FAIL |
this is a locker, and it did not verify |
| 2 | EXIT_USAGE |
what you pointed at is not a locker the tool can read, or --key could not be read |
The line between 1 and 2 is drawn deliberately, and the code says why: reporting a malformed archive as exit 1 would tell you your evidence had been tampered with when you had in fact mistyped a path. Exit 2 means the tool could not begin. Exit 1 means it began and the evidence did not hold up.
Exit 2 covers a path that does not exist, an archive that is not a readable zip,
a missing member, a member larger than the 10 MiB per-member cap
(10 × 1024 × 1024 bytes), JSON that will not parse, an unreadable
public_key.pem, a manifest entry missing any of seq, kind, phase,
size, sha256 or created_at, an unexpected member at the root of the zip,
and any member path that is absolute or contains ...
One case is deliberately routed the other way. An extra file smuggled into
artifacts/ is not rejected when the archive is opened; it is carried through to
the artifact checks and reported as unlisted_blob with exit 1. A zip and an
unpacked directory carrying the same smuggled file therefore give the same
verdict, and it is the serious one.
Machine-readable output¶
--json replaces the text report with the same result as JSON:
{
"ok": true,
"pinned": false,
"key_id": "5f79492518ca…",
"run_id": "3f9a1c2e-7b44-4d21-9a6e-0c5b8e2d14f7",
"chain_head": "af08f99ccd6a…",
"entry_count": 3,
"signed_at": "2026-08-26T01:04:11.229107+00:00",
"entries": [
{
"seq": 0,
"sha256": "f869ee4c0b89…",
"kind": "command_log",
"filename": null,
"ok": true,
"reason": null
}
],
"failures": []
}
pinned records whether --key was given, so a pipeline can reject an
unpinned PASS. The exit code is the same in JSON mode as in text mode.
What a PASS establishes¶
A pinned PASS establishes that the evidence in front of you is byte-for-byte what the organization's signing key committed to, in the order it committed to, with nothing added and nothing removed.
It does not establish that the test behind that evidence was competent, that it covered what it claims to have covered, or that the evidence set is everything that happened during the run. Those are separate questions with separate answers, set out in what verification does not prove.
If what you were sent was a link rather than a file, read share links before you rely on it.
Last updated 2026-10-21