- Rust 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The signed disposition's RFC 3161 token is now verified at B5, and F13/F14 decide on its proven time; with no token it reads INDETERMINATE. Signed-off-by: Justin Simpson <jsimpson@artefactual.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
| .cargo | ||
| .forgejo/workflows | ||
| LICENSES | ||
| schema | ||
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| CONTRIBUTING.md | ||
| DCO | ||
| deny.toml | ||
| README.md | ||
| REUSE.toml | ||
provenant-verify
The independent Rust verifier for Provenant (Spike 01). Given only a package (an OCFL object + its sidecar attestation set) and public trust anchors, and no access to Provenant, a11a2ocfl, their databases, or operators, it establishes object integrity, attestation validity, and event-graph closure, in the order fixed by the acceptance checklist.
It shares no code with the Go emitter (provenant-attest); everything is
re-derived from the package bytes and the published predicate schema. That
independence is the experiment (SC-2 / SC-8), not an implementation detail.
Status: a research prototype. It checks one package at a time, from the command line. It has not been run through a live preservation pipeline and is not integrated with any product Artefactual ships. In the article's demonstration the time authority is a local stand-in, not a public service.
In the article
This repository is experiment three of "Computed and Asserted": let a stranger check it, then kill an anchor. The verifier is handed a package that other tools built and signed, plus two public trust anchors that arrive by a separate channel: the signer's public key and the time authority's root certificate. It needs none of those tools to be present or running.
Figure 3a: the check passes
The command, as the demonstration driver runs it:
provenant-verify --package work/object \
--public-key trust/cosign.pub \
--tsa-root trust/tsa/ca.crt
An excerpt of the output (omitted lines are marked ...; exit code 0):
provenant-verify: PASS
package: work/object
[PASS] A1 Inventory well-formed
[PASS] A2 Bytes match the manifest
...
[PASS] B5 Timestamp valid
...
what this establishes:
[PASS] independently verifiable (no access to the producer needed) (SC-2)
[PASS] provenance complete (every object accounted for, every derivation resolved) (SC-3)
[PASS] an independent tool reaches the same result (SC-8)
Below the verdicts the verifier lists what it does not yet prove. For this package that list includes:
- agent identity resolved to a key (SC-4): identities are string-only stubs; deferred to Spike 03
- in-toto workflow-policy conformance (E12): this package asserts no policy, so there is nothing to check against
The numbers in the figure: the package holds 45 attestations. The signed package
disposition accounts for every object (11 attested, 7 declared exclusions). All 133
agent identities are reported as string-only stubs, which is why D11 reads STUB
and not PASS.
Figure 3b: an anchor is killed
The same command with a different timestamp root:
provenant-verify --package work/object \
--public-key trust/cosign.pub \
--tsa-root trust/wrong/tsa-root.pem
The article describes this step as deleting the time authority's certificate. What
the driver does is substitute a root certificate that did not issue the timestamps
(trust/wrong/tsa-root.pem). Leaving --tsa-root out altogether gives the same
result, with a different reason at B5.
An excerpt of the output (exit code 1):
provenant-verify: FAIL
package: work/object
...
[PASS] A2 Bytes match the manifest
...
[INDETERMINATE] B4 Authorized signature, made at a proven time
? 050aec85-8717-4ffc-81c2-f95e33242804.dsse.json: NO_POE: the signature verifies, but when it was made is not proven (its timestamp did not verify at B5)
...
[INDETERMINATE] B5 Timestamp valid
? 050aec85-8717-4ffc-81c2-f95e33242804.dsse.json: NO_CERTIFICATE_CHAIN_FOUND: TSA signing certificate does not chain to the supplied TSA root (no issuer found for an intermediate)
...
check not established: B5 Timestamp valid
B5 reads INDETERMINATE for all 45 attestations and for the signed package
disposition, for that one reason: the time authority's certificate does not chain
to the root supplied, so when each was made cannot be established (NO_CERTIFICATE_CHAIN_FOUND, in the
terms of ETSI EN 319 102-1). A timestamp that is wrong reads differently: a token
that does not match its attestation, or whose certificate was not valid when it
signed, reads FAIL. The bytes still match (A2) and every signature still
verifies; what is missing is proof of when. Anyone holding the signing key can
sign at any time, so a signature whose time is not proven is not shown to be
valid: B4 reads INDETERMINATE for each attestation, with the reason NO_POE (no
proof of existence). No check fails outright, and the verdict is still FAIL, exit
code 1: a check that is not established never counts toward a pass. SC-2 and SC-8
name B4 as the first check not established; the last line names B5, since B4's
NO_POE follows from it. SC-3, provenance completeness, still reads PASS.
Reading the figures against your own run
The figures were captured on 2026-09-04 with a build of this verifier from 2026-07-02. A build from this repository, run on the same package, gives the same verdicts and the same exit codes. In Figure 3a, B4 still passes, under a new title.
Figure 3b reads differently in three places. Each follows from a change made after an external review of the article:
- B4. The figure shows
[PASS] B4 Signature valid & authorized; a current build prints[INDETERMINATE] B4 Authorized signature, made at a proven time, with oneNO_POEline per attestation. A signature whose time cannot be proven no longer reads as valid. - B5. The figure shows
[FAIL] B5 Timestamp valid; a current build prints[INDETERMINATE] B5 Timestamp valid, with the same reason marked?and prefixedNO_CERTIFICATE_CHAIN_FOUND. A timestamp that cannot be chained to the root no longer reads as wrong. B5 now also checks the package disposition's timestamp, so one more line namesdisposition.dsse.json. - The check named. The figure's SC-2 and SC-8 reasons name B5 as the first
failure, and its last line is
first failing check: B5 Timestamp valid. A current build names no failure, since no check fails outright: the reasons name B4 as the first check not established, and the last line ischeck not established: B5 Timestamp valid.
The text also differs in three ways, and none of them changes a result:
- Two further check lines, F13 and F14, have been added since. Both read
N/Afor this package, which is not anchored in a transparency log. - The SC-6 line in the list of what is not yet proven, and the note that goes with it, are reworded. Inclusion proofs are now implemented (F13), so the line says that this package is not anchored.
- Punctuation is plain ASCII. The figures show dashes and an ellipsis.
The figures also show two summary lines, one counting the further attestations that failed the same check and one counting the agent identities. The demonstration driver writes those: it condenses repeated lines for the screen. The verifier itself prints one line per attestation and one per identity.
Build
A standard Cargo crate. The toolchain is Rust 1.96.0, the version baked into
the CI image (see .forgejo/workflows/ci.yml); confirm with cargo --version
before building. From this directory:
cargo build --release # release binary at ./target/release/provenant-verify
cargo build # debug binary at ./target/debug/provenant-verify
Or build and run in one step with cargo run --release -- <args> (see Usage
below). The first build fetches crates over the network; cargo test additionally
needs python3 and openssl on PATH to build its fixtures (see Tests / fixtures).
Usage
provenant-verify \
--package <OCFL object root> # contains inventory.json + provenant/attestations/
--public-key <signer.pub PEM> # authorized signer (trust anchor)
--tsa-root <tsa-root.pem> # RFC 3161 TSA root (trust anchor)
[--log-key <keyfile>] # transparency log's note verifier key; enables F13
[--require-anchor] # fail when the package is not anchored at all
[--witness-key <keyfile>] # require that peer witness's cosignature
[--distrust <file>] # a compromise-declaration notice; enables F14 (needs --log-key)
[--format text|json|explain] # default: text
[--explain] # shorthand for --format explain
provenant-verify --help has the full text for each option.
Exit code 0 iff SC-2 passes; 1 on a verification failure; 2 on a fatal
input error (unreadable package / bad anchor). Output is a single pass/fail plus
a per-step report: every check with its status, every stub named explicitly, and
on failure the first failing check or, when no check failed outright, a check not
established: B5 when it could not establish the time (a NO_POE at B4 follows from
it), else the first such check. A check reads INDETERMINATE when the verifier
cannot establish its property either way, such as a signature whose time is not
proven, or a timestamp that is missing or cannot be chained to the TSA root; it
never counts toward a pass, so a package with one exits 1.
Silence is never a pass.
Output modes. text (default) leads each verdict with a plain-language label
(the SC / A1-E12 codes stay as a secondary trace tag) and names what this does
not prove, criteria not yet implemented (SC-4 agent identity, the SC-6 inclusion
proof, E12 policy), without ever labelling a passing check that way. explain is
a full narrative for a non-expert reader (an archivist, a security reviewer): what
each check means in prose, per-object/per-event findings, and what is not proven. json
is the stable machine format for tooling and tests. In it, each step's status is
pass, fail, stub, not_applicable or indeterminate, and an indeterminate
step lists its reasons under indeterminate. Each event carries
signature_verifies (the cryptographic check alone) and signature_validity
(passed, indeterminate or failed); together they replace the former
signature_valid. Note the two senses of
"layout": E12 is the in-toto workflow policy; a package structure error
(error.rs) is the on-disk arrangement.
What it checks (the ordered law)
| Step | Check | Section |
|---|---|---|
| A1 | Inventory well-formed; declared digestAlgorithm read; sidecar digest matches |
P1 |
| A2 | Bytes match the manifest (sha512) + the fixity block (sha256) | P1 |
| B3 | DSSE envelope valid (payloadType = application/vnd.in-toto+json) |
P2/P3 |
| B4 | Signature verifies over the DSSE PAE under an authorized key (else FAIL), and B5 proves when it was made (else INDETERMINATE, NO_POE, per ETSI EN 319 102-1) |
P2/P3 |
| B5 | For each attestation and a verified package disposition: RFC 3161 timestamp present (else INDETERMINATE, NO_POE), binds to that envelope and verifies, its TSA certificate valid at genTime (else FAIL), and chains to the supplied TSA root (else INDETERMINATE, NO_CERTIFICATE_CHAIN_FOUND, also when no root is given) |
P8 (SC-6 partial) |
| B6 | in-toto Statement v1 + predicateType v0.1 + strict schema validation |
P2/P3 |
| B7 | Three-way join: every subject.digest == the OCFL inventory digest |
P1-P3 |
| C8 | No unattested objects (every byte-bearing object is a subject) | P3/P9 |
| C9 | Derivation resolves (derivedFrom -> present + attested) |
P3/P9 |
| C10 | Causal graph sound (connected, acyclic, no dangling parents) |
P3/P9 |
| D11 | Agent present; Spike-01 string identities flagged as stubs | P5 |
| E12 | Layout conformance if asserted; else "no layout asserted" | P4 |
| F13 | Anchored in a transparency log: every envelope proves inclusion (else FAIL), and with a logged warrant each attestation and a verified package disposition was signed inside the warrant's window at a time B5 proved (outside: FAIL; time not proven: INDETERMINATE, NO_POE). N/A unless --log-key is given and the package is anchored |
SC-6 |
| F14 | Distrust declarations honored: signatures by the declared key (on attestations and a verified package disposition) made inside the declared window, at a time B5 proved, fail; those B5 proved outside it stand; one whose time is not proven reads INDETERMINATE, NO_POE. N/A unless --distrust is given |
- |
Steps A1 to E12 are the twelve steps of the acceptance checklist. F13 and F14 were added later, with the transparency-log work of experiment four, and the checklist does not yet describe them.
Verdicts
- SC-2 (independent verifiability) passes iff steps 1-11 hold. A step that
reads
INDETERMINATEdoes not hold. - SC-3 (provenance completeness) passes iff steps 8-10 hold.
- SC-8 (protocol over application): this tool reports whether steps 1-7 hold from Path B; SC-8 proper requires an independent Path-A run, made with stock tools and kept outside this repository, to reach the same verdict (the differential check).
Design notes / things flagged
- Crypto is from-scratch, not Sigstore-SDK. DSSE PAE is reconstructed by
hand and signatures verified with low-level RustCrypto primitives
(
p256ECDSA-P256/SHA-256,ed25519-dalek,rsa). This both avoids the less mature Rust Sigstore stack and keeps the verifier free of any library a producer might also use. - RFC 3161 / CMS is hand-walked. The RustCrypto
dercrate enforces DERSET OFcanonical ordering so strictly that it rejects valid tokens emitted by openssl/cosign ("SET OF contains duplicate"). The timestamp path therefore walks theSignedDataASN.1 manually for the fields it needs, while still verifying the SignerInfo signature, themessageDigestattribute, and the signer-to-root certificate chain. EC and RSA TSAs are supported. - Timestamp binding target. The checklist says the timestamp is "over the signed payload", but real cosign timestamps the signature. Rather than risk a false NO-GO, B5 accepts an imprint that matches the signature, the payload, or the PAE, and reports which; all three bind the time anchor to this attestation. (Worth pinning down against the real emitter's output and tightening.)
- Transparency-log inclusion proof is checked when
--log-keyis given (F13). For a package that is not anchored, its absence is recorded as a note, never a failure, unless--require-anchorasks for one. - Agent identity (D11) is a known stub in Spike 01:
spiffe://...,premis-agent, andx509-fingerprintidentities are reported as "string-only, unresolved" and are not counted as the SC-4 "resolves to a key" pass. SC-4 proper arrives with live SPIFFE/SLSA in Spike 03.
Tests / fixtures
tests/fixtures/gen.py builds a real package using only stock tools
(openssl): an EC P-256 signer, DSSE-over-PAE signatures, and a 2-cert RFC 3161
TSA chain, sharing no code with any emitter. tests/integration.rs verifies the
good package passes and that targeted tampering fails the correct check (byte
drift -> A2, sidecar corruption -> A1, broken/unauthorized signature -> B4, unknown
predicate field -> B6, dangling parent -> C10, swapped timestamp -> B5), that a
signature with no proven time leaves B4 INDETERMINATE, not PASS, and that a
timestamp that is missing or cannot be chained to the TSA root leaves B5
INDETERMINATE while a wrong one fails it. With a wrong TSA root or none, the
warrant window (F13) and the distrust window (F14) read INDETERMINATE, one
NO_POE line per envelope, instead of deciding on a time the token only claims.
The signed package disposition is held to the same checks: its token is verified
at B5, a covering distrust window fails it by name at F14, and with no token it
reads INDETERMINATE (NO_POE). A rejected disposition is ignored.
cargo test # needs python3 + openssl on PATH to build fixtures
The tests need no sample data: every fixture is generated at test time. One more
test, tests/ascii.rs, keeps the repository's text plain ASCII (LICENSES/ is the
only exception).
Continuous integration. CI of record runs on the maintainers' sovereign runner;
the public copy runs the same workflows where the host allows. The workflows are in
.forgejo/workflows/: ci.yml (format, lint, tests, advisory audit, SBOM) and
compliance.yml (REUSE licensing and the DCO sign-off check). Their checkout step
tries the forge's own URL first and falls back to the maintainers' in-network forge.
Dependencies (SC-7 posture)
Direct dependencies: serde/serde_json, sha2, jsonschema (network features
disabled: no remote $ref fetch), p256, ed25519-dalek, rsa,
cms/x509-cert/der/spki for ASN.1, clap. Every crate in the dependency tree
is under a permissive, OSI-approved licence; deny.toml holds the allow-list and
cargo deny check enforces it, along with the advisory, ban and source policies.
No proprietary or source-available component in the verify path; no network access.
One RustSec advisory is ignored, with the reason recorded beside it in deny.toml
and .cargo/audit.toml: RUSTSEC-2023-0071, a timing side-channel in the rsa
crate's private-key operations. This verifier only ever verifies RSA signatures with
a public key, so that path is not reachable here.
AI assistance
Most of this repository, code and documentation, was written with an AI coding
assistant (Claude, by Anthropic), directed by the maintainer. The project's policy
is ADR-08, which
mirrors the Linux kernel's: AI-assisted contributions are permitted and must be
disclosed with an attribution trailer such as Co-Authored-By: or Assisted-by:;
an AI cannot provide the Signed-off-by. A human signs off every commit on main
under the Developer Certificate of Origin and so takes responsibility for the
contribution's origin and licence.
Licensing and contributing
Code is Apache-2.0 and documentation is CC-BY-4.0; every file carries an SPDX header
or is declared in REUSE.toml, and the licence texts are in LICENSES/.
You are welcome to fork the repository, try it out, and open issues. For now the repository is read-only except to its maintainer, and pull requests are not accepted. CONTRIBUTING.md says how commits here are made.