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