trust-ledger-verifier
Offline verification of trust-ledger/v1 and trust-ledger/v2 evidence packs. Distribution name is
trust-ledger-verifier (the import package and CLI stay verifier / trust-verify — the
plain name verifier is already taken on PyPI by an unrelated project).
Install
pip install trust-ledger-verifier
Usage
trust-verify path/to/pack
trust-verify path/to/pack --reference-values values.json
trust-verify path/to/pack --tsa-trust-profile my-profile.json
trust-verify path/to/pack --audit-challenge challenge.json
trust-verify path/to/pack --evidence-profile profile.json --appraisal-context context.json
trust-verify path/to/pack --producer-registry registry.json
trust-verify path/to/pack --challenge-ledger my-challenges.json
trust-verify path/to/pack --json # machine-readable, for CI
# Build the appraisal context (digests of your documents, as one bindable object).
trust-context --out context.json --schema v3 \
--evidence-profile profile.json --reference-values values.json \
--tsa-trust-profile-builtin --no-validator-set \
--producer-registry registry.json --challenge-ledger my-challenges.json \
--accepted-pack-schema trust-ledger/v2 --require-anchor --require-challenge
trust-challenge --relying-party my-org --valid-for 24h > challenge.json
trust-challenge --relying-party my-org --appraisal-context context.json > challenge.json
# The only command here that writes: record that you accepted this pack as the answer.
trust-redeem path/to/pack --ledger my-challenges.json --audit-challenge challenge.json \
--init --ledger-id my-org-challenges/2026 --relying-party-id my-org \
--authority "besluit DPO-2026-014"
Exit codes: 0 = PASS, 1 = not PASS (FAIL / PARTIAL / claimed-only / unknown version),
2 = the pack could not be loaded at all.
--reference-values
The trusted basis a session's claims are checked against, supplied by you, the relying
party. Without it, the verifier falls back to the values the pack itself claims, and the
verdict can never be a full PASS — at best claimed-only. This is intentional: a pack
grading its own homework is not evidence.
--tsa-trust-profile
Same principle, applied to anchoring: the TSA trust profile always comes from the relying party, never from the pack. A pack can carry an RFC 3161 token, but it does not get to say which timestamp authorities you trust — that would let a producer anchor against a TSA it controls and call it proof.
Without --tsa-trust-profile, the verifier uses a bundled default
(sigstore-public-tsa.json, shipped as package data) pointing at the public
timestamp.sigstore.dev service. The report always names which profile judged the anchor,
including a digest of the profile document, so a reader can tell "trusted by the default
bundle" apart from "trusted by your own profile" without re-reading the code.
To supply your own: the bundled file is the format — copy it and adjust roots,
intermediates, allowed_policy_oids, allowed_tsa_spki_sha256,
allowed_hash_oids / allowed_signature_oids, and revocation.policy
(required or if-available, depending on whether your TSA publishes a CRL).
--audit-challenge
Third input of the same kind, and the one that answers a question the other two cannot: is
this the pack I asked for? You mint a challenge with trust-challenge before the
execution, send it to the producer, and keep your copy. The producer's ledger binds it at
genesis, so the whole chain hangs off it — a clean pack from last month cannot be handed
over as an answer, and a challenge pasted into a manifest afterwards is a FAIL.
Without it, the pack is unchallenged and reaches at most PARTIAL: nothing then ties it to
the execution you had in mind, and the producer picked both the session id and which sealed
session to show you.
⚠️ What a match does and does not prove. It proves the declaration was made after your challenge existed (statement freshness). It does not prove the described execution happened after it: a producer can keep the measured values of an older run and wrap fresh, correctly bound records around them. RFC 9334 makes the same point about nonces. Proving execution freshness needs the challenge to be consumed by the execution or measurement layer itself, which is outside this project.
--mode period_epoch exists for continuously running systems that cannot fetch a challenge
per session. It is an epoch identifier: it proves the declaration fell inside your window and
nothing more — no coverage, no completeness — and it therefore reaches challenged but never
a full PASS.
--evidence-profile and --appraisal-context
Fourth input of the same kind, answering the question the other three still leave open: did
the producer prove the thing I asked about, or only the things that suited him? Every check
before this one asks whether what is present is valid. None of them asks whether what had to
be present is there — so a producer answering "did this workload leak to the cloud?" can emit
one blameless inference.local record, emit no routing.decision at all, and pass without
falsifying a single field.
The evidence profile states which claim types must be present, on whose authority
(issuer) and under which norm (norm_reference, mandatory — also for a purely internal
policy). Both sit inside the profile's digest: origin you can edit afterwards is not origin.
A missing required claim type is a FAIL naming that type, not a downgrade — there was an
explicit requirement and the evidence does not meet it.
Two norms at once is the normal case (an AI Act art. 12 obligation next to a GDPR art. 30
one), so a profile can be composite: components written out in full, composition: "union" as the only allowed value, and the written-out required_claim_types checked against
the union of the components. An intersection of two norms would be a third, weaker norm.
The appraisal context binds the profile together with everything else that steers the
verdict — reference-values, validator set, TSA trust profile, producer registry, require_anchor /
require_challenge — as one object, each pin optional (null = do not pin). Mint your
challenge with trust-challenge --appraisal-context context.json and its digest goes into the
challenge and therefore into genesis: the producer knew up front which evidence he owed, and
nobody can swap the norm afterwards. If your challenge binds a context and you do not supply
it at verification time, coverage is unevaluated and the pack reaches at most PARTIAL —
"no requirement" and "a requirement I cannot test" are different statements.
Without any profile the pack is uncovered, and that alone does not degrade the verdict:
with no stated norm there is nothing to test coverage against.
⚠️ What covered does and does not prove. It counts claim types, not executions. It
rules out "not one record of this type"; it does not rule out "one blameless record and the
rest omitted". For a positive claim ("show that you inferred locally") it is strong; for a
negative one ("show that nothing was transferred") it is nearly nothing. If your norm needs
per-execution coverage, set requires_event_coverage: true and the verdict degrades with that
reason rather than showing a green axis your norm did not ask for.
Writing the context: trust-context
The evidence profile you write by hand — it is a small document and the real question in it
(required_claim_types) is yours to answer. The context is different: it is almost entirely
digests over the JCS-canonical form of the other documents, so trust-context computes it
for you, using the same code the verifier uses:
trust-context --out context.json --schema v3 \
--evidence-profile profile.json \
--reference-values values.json \
--no-validator-set \
--tsa-trust-profile-builtin \
--producer-registry registry.json \
--challenge-ledger-id my-org-challenges/2026 \
--accepted-pack-schema trust-ledger/v2 \
--require-anchor --require-challenge
Every pin is an explicit either/or — a path, or --no-<thing> — and leaving a group unanswered
is refused. That is not ceremony: null here means "I chose not to pin this", and a flag you
merely forgot would say the same thing without anyone having chosen it. --schema is stated for
the same reason: a v2 document does not say "unpinned on purpose", it says "this schema had no
such field", and only you can say which of the two you mean.
It creates a document; it never updates one. Overwriting is refused without --overwrite,
because an outstanding challenge may already bind the digest of the file that is there — and
replacing it does not make that challenge stricter, it makes it unjudgeable.
It will not write your evidence profile. Deciding which claim types your norm requires is the audit work, and a tool that guessed it would hand you a norm nobody wrote.
--producer-registry
Same principle again, on the axis that says who. The pack supplies its own public key, and
key_id is provably derived from that key — but derived-from-the-key-the-pack-supplied is an
internal consistency property, not an identity. Without a registry, a producer can re-sign an
entire pack with a fresh key under any name he likes and everything still verifies.
The registry (trust-ledger/producer-registry/v1) says which keys you enrolled, under which
name, and on what basis:
{
"producer_registry_schema": "trust-ledger/producer-registry/v1",
"registry_id": "toezichthouder-x-producenten/v3",
"issuer": {"relying_party_id": "toezichthouder-x", "authority": "besluit DPO-2026-014",
"issued_ts": "2026-07-28T00:00:00Z"},
"entries": [
{"producer_id": "gateway", "key_id": "ed25519-…",
"enrollment_basis": "sleuteloverdracht ter plaatse, protocol 2026-03-11",
"enrolled_ts": "2026-03-11T09:00:00Z",
"status": "active", "status_since": null, "status_reason": null}
]
}
entries is a list, not a map, because a producer holds several keys over time and the
historical entry has to stay readable to judge old packs. status is active, superseded
(retired by rotation — still covers what it signed before status_since, judged against the
anchor's genTime) or revoked (compromised — rejects regardless of date, because a
compromise works backwards). Without a trusted anchor there is no genTime to judge a
rotation against, and a superseded entry is then not talked free by a sealed_ts the
producer wrote himself.
enrollment_basis is mandatory and checked by nothing. That is deliberate: the enrolment is
where the trust comes from, so a registry that omits it records only its conclusion — but no
field could verify it, and a field that looks like a control while enforcing nothing is worse
than an honest note.
⚠️ Without --producer-registry the pack is unregistered and reaches at most PARTIAL.
Note that this differs from uncovered, which does not degrade: with no evidence profile
there is no requirement to test against, but a pack always claims authorship, so a full
PASS would be attesting a claim nobody checked. There is deliberately no bundled default
registry either, unlike the TSA trust profile: for timestamp authorities a publicly
recognised party exists, for producer identity none does.
⚠️ What registered does and does not prove. It proves this is the key you enrolled under
this name — not which legal person is behind it (the registration is exactly as strong as the
enrolment, which happened outside this system), and not that nobody else holds a copy of the
key. There is no HSM binding and no hardware attestation here: whoever holds the private key
is the producer.
--challenge-ledger and trust-redeem
The format has said challenge_mode: "single_execution" since the challenge existed, and until
now nothing tested the word single where it promises something: that one execution also
yields one answer. A producer could hand over pack A, wait for a dispute, and then "find" pack
B — same session, same challenge, same enrolled key, another story. Both passed.
The challenge ledger (trust-ledger/challenge-ledger/v1) is your own record of which challenge
you accepted which pack as the answer to:
{
"challenge_ledger_schema": "trust-ledger/challenge-ledger/v1",
"ledger_id": "toezichthouder-x-challenges/2026",
"issuer": {"relying_party_id": "toezichthouder-x", "authority": "besluit DPO-2026-014",
"issued_ts": "2026-07-29T00:00:00Z"},
"entries": [
{"challenge_digest": "sha256:…", "anchor_digest": "sha256:…",
"accepted_ts": "2026-07-29T11:04:00Z", "accepted_verdict": "PASS", "note": null}
]
}
A pack is identified by its anchor_digest, which covers the whole file inventory and is
stable across re-anchoring — so verifying or redeeming the same pack twice is no contradiction.
Unanchored packs have no such identity and are reported unevaluated rather than falling back
to something weaker. Two entries with different anchor_digest under one challenge are
contradicted: a FAIL, and it hits the first pack as hard as the second. Which of the two
is the lie cannot be established; that one of them is untrue can.
trust-verify only ever reads the ledger. trust-redeem verifies a pack and appends an entry —
never on a FAIL, never on a contradiction (that is already recorded), never for a pack without
a stable identity — writing atomically. Splitting the two keeps verify_pack a pure function
of (pack, documents): a verifier that wrote would make its verdict depend on how often it had
run before.
⚠️ Without --challenge-ledger the axis is unchecked, and unlike unregistered this does
not degrade the verdict. A pack never claims to be the only answer to its challenge, so
nothing goes unchecked here — the absent thing is an administration you keep. This is the
uncovered side of that asymmetry, not the unregistered side.
⚠️ This is not fork detection. It proves at most that you accepted no second, different
pack under this challenge: non-repudiation, not uniqueness. A contradictory pack given to
another party, or to nobody, stays invisible. And this is the one input that grows: pinning in
the appraisal context uses challenge_ledger_id, an identity rather than a digest (the ledger
grows, so a digest bound at mint time would be stale by definition), which means you can drop
an entry from your own administration without the binding noticing. Lose the file and the
guarantee goes with it.
What a verdict means
- PASS — every universal check passed (schema → sequence → signatures → chain, in that
fixed order), every payload validator against the trusted reference values passed, the pack
is anchored (
anchor_status == "anchored") against a profile you accepted, and it is bound to the challenge you issued (challenge_status == "challenged",challenge_mode == "single_execution"), and the signing key is enrolled in the registry you supplied (identity_status == "registered"), and — when your challenge binds a norm — that norm was supplied and met, and your challenge ledger, if you keep one, holds no other answer to this challenge (answer_statusisanswered-onceorunchecked). Each axis is separately necessary: an unanchored pack is provably truncatable, an unchallenged one is provably interchangeable, and an uncovered one is provably too narrow. - PARTIAL (reported as
claimed-onlyin the verdict when trust_basis is weak, or a PASS degraded by anchor or challenge status) — universally valid, but either no external reference values were supplied, or the pack isn't anchored, or anchoring couldn't be evaluated with the trust profile you gave it (unknown root, wrong policy, revoked signer), or it isn't bound to a challenge. This is not a bug in the pack; it's a correct, weaker conclusion. - FAIL — a named check failed: a broken hash-chain link, an invalid signature, a payload
that violates the reference values, an anchor whose token doesn't match the pack's digest
(post-anchoring tampering), or a pack that answers a different challenge than the one you
supplied (
challenge-mismatch) or falls outside its window (challenge-expired), or a required claim type that is simply absent (coverage-gap), or a supplied norm that is not the one your challenge bound (appraisal-context-mismatch— never merged, never resolved in favour of the stricter one), or a signing key your registry never enrolled (unknown-key,revoked), or a second, different pack under a challenge your ledger already recorded an answer to (contradicted), or a challenge ledger that is not the one your context pinned (wrong-ledger). - unknown version — the pack's schema version is one this verifier doesn't know. Treated as a separate outcome, not silently as FAIL or PASS.
Every text report ends with a mandatory proof-boundary paragraph naming what is and is not
proven for that specific pack — completeness, identity, key custody, uniqueness, relevance,
time. Read it; it is not boilerplate, and it changes depending on anchor, challenge, coverage
and identity status. See
the root
README.md for the project-wide version of this list and for what is not yet built at all
(fork detection, per-execution coverage).
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file trust_ledger_verifier-0.6.0.tar.gz.
File metadata
- Download URL: trust_ledger_verifier-0.6.0.tar.gz
- Upload date:
- Size: 116.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
11c9d1610b411c2ffe16916ecf5829a170d8929c4d9bdd4300552c796ad67df1
|
|
| MD5 |
4c96677f3683a0835e8f489b83342abd
|
|
| BLAKE2b-256 |
322e3cd1130d46fd302d828460e7a9842419034261556af7479dac1402297be6
|
Provenance
The following attestation bundles were made for trust_ledger_verifier-0.6.0.tar.gz:
Publisher:
publish-pypi.yml on duncanprins/trust-ledger
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
trust_ledger_verifier-0.6.0.tar.gz -
Subject digest:
11c9d1610b411c2ffe16916ecf5829a170d8929c4d9bdd4300552c796ad67df1 - Sigstore transparency entry: 2339689563
- Sigstore integration time:
-
Permalink:
duncanprins/trust-ledger@4d7a70c375d0ed6a19ed9864d8e0f0e0a8df6d14 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/duncanprins
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@4d7a70c375d0ed6a19ed9864d8e0f0e0a8df6d14 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file trust_ledger_verifier-0.6.0-py3-none-any.whl.
File metadata
- Download URL: trust_ledger_verifier-0.6.0-py3-none-any.whl
- Upload date:
- Size: 87.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aa91777cc8d9802ad7cd3f8a402d96991a15cbefa5c1fdba1178d771d6d3a0eb
|
|
| MD5 |
19152b6159be9cb2d2a2629cfeba6f7f
|
|
| BLAKE2b-256 |
fe0b4da9d2f52649975b0513fbce922aafac8acf4aa47651c43009fa2d7e5a2f
|
Provenance
The following attestation bundles were made for trust_ledger_verifier-0.6.0-py3-none-any.whl:
Publisher:
publish-pypi.yml on duncanprins/trust-ledger
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
trust_ledger_verifier-0.6.0-py3-none-any.whl -
Subject digest:
aa91777cc8d9802ad7cd3f8a402d96991a15cbefa5c1fdba1178d771d6d3a0eb - Sigstore transparency entry: 2339689567
- Sigstore integration time:
-
Permalink:
duncanprins/trust-ledger@4d7a70c375d0ed6a19ed9864d8e0f0e0a8df6d14 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/duncanprins
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@4d7a70c375d0ed6a19ed9864d8e0f0e0a8df6d14 -
Trigger Event:
workflow_dispatch
-
Statement type: