The hash chain
Content addressing, the chain built once at sealing, and the manifest that records it.
Content addressing pins each artifact to its own bytes: change one byte and the hash changes, so the name no longer fits the file. It says nothing about the set of artifacts — which ones a run produced, in what order, or whether one was removed afterwards. The chain covers that, and is built once, at sealing, from the artifacts of one run ordered by their position in it.
Genesis¶
The chain starts from a value derived from the run itself:
genesis = sha256("pentr-evidence-locker-v1|" + run_id)
run_id is the engagement id. Two runs therefore start from different values,
and a chain lifted from one run does not recompute under another run's id.
Entries¶
Each artifact becomes one entry. The entry's hash is taken over three things concatenated in 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 holds the entry's position, kind, phase and size, its sorted control identifiers, its sorted finding identifiers, its filename and its creation time. The artifact's hash is not inside that object; it is concatenated beside it.
The first entry's prev_hash_hex is the genesis value. Every entry after that
takes the hash of the one before, so each entry's hash depends on the whole
prefix of the chain.
Chain head¶
The chain head is the hash of the last entry. A run with no artifacts has a
chain head equal to its genesis value, and a manifest reporting entry_count:
0. That is a reachable state rather than a theoretical one — see
Sealing.
Because each entry's hash covers the previous one, the head commits to every artifact in the run and to their order together. Changing one artifact's bytes, editing an entry's metadata, removing an entry or swapping two of them all produce a different head.
Canonical encoding¶
Every JSON string the chain hashes is serialised with the same three settings:
| Setting | Effect |
|---|---|
sort_keys=True |
Keys appear in a fixed order, not insertion order. |
ensure_ascii=False |
Non-ASCII characters are written as UTF-8, not as \uXXXX escapes. |
separators=(",", ":") |
No space after a comma or a colon. |
Verification recomputes these strings from the manifest's own fields and hashes them again. One differing byte — a space after a colon, a key in a different place, an escaped accent — produces a different hash, so the entry hash differs, so the chain head differs, so the check fails. Pretty-printing a manifest, or round-tripping it through a tool that reorders keys, invalidates the signature over it.
The offline verifier imports the same module that sealed the run, which keeps the two from drifting apart.
The manifest¶
The manifest is the JSON document that records the finished chain. It holds:
- the format version,
pentr-locker-1, and the chain algorithm,sha256-linear-v1 - the genesis value
- a
runblock: the engagement id, the framework, the scope digest, and the run's start and end times - one object per entry, repeating the entry's hash and the metadata it was computed from
- the chain head and the entry count
The manifest is serialised with the same canonical settings, and its own SHA-256 is computed over those bytes.
That hash is not itself the signed payload. What gets signed is a separate canonical document, the signature block, holding the signature version and algorithm, the chain head, the manifest's SHA-256, the signing key's id and the signing time. The manifest's hash is one field inside it. Verifying a locker means recomputing the manifest's hash, checking it against the copy in the block, and then verifying the signature over the block's bytes — not over the manifest. Sealing covers why the block is the signed unit.
The signature therefore covers the chain head, and through it every artifact, indirectly.
The scope digest¶
The run block carries a digest rather than the scope itself:
scope_digest = sha256(canonical({"scope_in": [...], "scope_out": [...], "rules": {...}}))
scope_in and scope_out are the run's scope lists and
rules is its rules of engagement. The
digest binds the sealed evidence to the authorization it was produced under: a
recipient who is told what the scope and rules were can recompute the digest and
check it against the manifest. It is a commitment, not a disclosure — the digest
alone does not reveal the lists.
What the chain does not cover¶
The chain covers content and order. It does not cover time: the manifest's start
and end times and the seal's signed_at are the platform's own clock,
self-asserted, with no trusted timestamp. It does not cover completeness either
— it commits to the artifacts that were sealed, and cannot show that some other
capture was made and never recorded. And on its own it establishes nothing about
who produced it; that is the signature. What
verification does not prove sets out the rest.
Last updated 2026-09-22