Metadata-Version: 2.4
Name: trust-ledger-verifier
Version: 0.5.1
Summary: Trust Ledger verifier: offline evidence-pack verification (universal checks + plugin validators) for trust-ledger/v1 and /v2.
Author: Duncan Prins
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/duncanprins/trust-ledger
Project-URL: Repository, https://github.com/duncanprins/trust-ledger
Project-URL: Changelog, https://github.com/duncanprins/trust-ledger/blob/main/verifier/CHANGELOG.md
Project-URL: Issues, https://github.com/duncanprins/trust-ledger/issues
Keywords: audit,provenance,evidence,verification,ed25519,rfc3161,offline,ai-act
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Legal Industry
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security :: Cryptography
Classifier: Topic :: System :: Logging
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: trust-ledger-core<0.4.0,>=0.3.0
Provides-Extra: test
Requires-Dist: pytest>=8; extra == "test"
Dynamic: license-file

# 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

```bash
pip install trust-ledger-verifier
```

## Usage

```bash
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

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.

### `--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:

```json
{
  "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:

```json
{
  "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_status` is `answered-once` or `unchecked`). 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-only` in 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).
