No description
  • Go 98.7%
  • Shell 1.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Justin Simpson 93c1abd03f Public baseline (article: experiment two)
provenant-attest as published with the "Computed and Asserted" article.
Earlier history is kept on the maintainers' private forge.

Signed-off-by: Justin Simpson <jsimpson@artefactual.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 08:30:00 +01:00
.forgejo/workflows Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00
cmd/provenant-attest Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00
internal Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00
LICENSES Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00
reports Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00
scripts Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00
testdata Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00
.gitignore Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00
CLAUDE.md Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00
CONTRIBUTING.md Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00
DCO Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00
go.mod Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00
go.sum Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00
README.md Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00
REUSE.toml Public baseline (article: experiment two) 2026-09-23 08:30:00 +01:00

provenant-attest

The Go emitter for Provenant: it reads the PREMIS events recorded in an Archivematica package's METS, inside an OCFL object, and emits one in-toto attestation per event, using the PreservationEvent v0.1 predicate. Each attestation is wrapped in DSSE, signed with cosign, timestamped under RFC 3161, and bound to the object's files by their OCFL inventory digests.

Status: prototype; one package at a time; not integrated with shipped products.

  • The predicate schema: preservation-event-v0.1.schema.json in the provenant umbrella repository (a byte-identical copy is vendored under testdata/teste-fixture/ for the tests)

In the article

This repository is experiment two of the article "Computed and Asserted": sign the package's own events. It reads the events Archivematica already recorded in the METS of the object that experiment one (provenant-import) produced, and signs each one where it stands. Figure 2 of the article is the tally this tool prints.

The command, run on experiment one's output (work/object and work/reports), with keys made by scripts/setup-signing.sh (see Run):

provenant-attest -object work/object -all -import-event work/reports/import-event.json \
  -key .keys/cosign.key -pub .keys/cosign.pub \
  -tsa-config .keys/tsa/tsa.cnf -tsa-cacert .keys/tsa/ca.crt \
  -schema ../provenant/preservation-event-v0.1.schema.json -reports work/reports

What it prints (a run of this repository on 2026-09-23, on the same AIP as the figure):

- inventory: urn:uuid:76b9e633-de6e-437c-a3d3-80f7f6b2c48c (digestAlgorithm=sha512, 18 manifest entries)
- mets: 11 file objects, 48 events, 3 agents, 1 intellectual entities
- emitted 44 event attestations (4 transforming with derivedFrom); skipped 4 unresolvable
- import event: ingestion (2f52044a-7880-8eab-9d47-31501a73e728) subject=metadata/mets.xml -> work/provenant/attestations/import-2f52044a-7880-8eab-9d47-31501a73e728.dsse.json
- disposition: work/provenant/disposition.dsse.json (11 attested, 7 declared exclusions)
- reports: work/reports/{lossy-field-log.json,lossy-field-log.md,exclusions.json}
OK Phase 2 attestation set complete.
  • 48 PREMIS events are recorded in the AIP's METS.
  • 44 of them are signed and timestamped, one attestation each. Four are transforming events, so their attestations also name the file they were derived from (derivedFrom).
  • 4 cannot be bound to a file in the object and are not signed. All four (ingestion, message digest calculation, virus check, format identification) describe the same file: the transfer METS, which experiment one sets aside on purpose. They are not dropped silently: each is listed with its reason in the lossy-field log (reports/lossy-field-log.md, "Skipped / unresolvable events"). That log is a report; it is not signed.
  • 1 more attestation signs experiment one's import record as a PreservationEvent (eventType ingestion), bound to the AIP's METS. Its signed facts include experiment one's accounting: 19 source payload entries, 18 preserved by digest, 1 dropped by the import profile.
  • That makes 45 attestations, each with its timestamp token: 90 files under work/provenant/attestations/, beside the object rather than inside it, so the object stays valid under a stock OCFL validator.
  • The signed accounting statement is disposition.dsse.json (with its disposition.tsr). It accounts for all 18 files in the object's inventory: 11 are subjects of attestations and 7 are declared exclusions, each with a reason (six Archivematica log files and README.html, none of them a PREMIS object of preservation). No file is left unaccounted for.

Two small predicates are invented. PreservationEvent v0.1 (https://provenant.dev/PreservationEvent/v0.1) lets an in-toto Statement carry a PREMIS event whole and unchanged. PackageDisposition v0.1 (https://provenant.dev/PackageDisposition/v0.1) is the signed accounting statement: it lists every file in the package, each either the subject of signed events or a declared exclusion with a reason. Its schema is embedded in the binary from internal/predicate/package-disposition-v0.1.schema.json. The rest is consumed: in-toto Statement v1, DSSE, cosign, RFC 3161 timestamps (openssl ts), OCFL.

Reading figure 2 against your own run. The figure was captured on 2026-09-04 with an earlier build. Every count above is the same in the capture and in this run, including the 90 files. Three things differ, none of them a number: this build prints - and OK where the capture shows a bullet and a check mark; it writes the attestations beside the object (work/provenant/), where the capture's build wrote them inside it (work/object/provenant/); and the import event's identifier is new on every run of provenant-import. The keys are generated locally, so signatures and timestamps differ from run to run by design.

Experiment three (provenant-verify) checks this output independently, given the package directory and the two public anchors: on the run above, provenant-verify --package work --public-key .keys/cosign.pub --tsa-root .keys/tsa/ca.crt reports PASS.

Run

Requires Go 1.26 (the latest 1.26.x patch release is recommended; the runs above used go1.26.8), cosign v3 (v3.0.6 above) and OpenSSL 3 for the local timestamp authority.

# 1. one-time: a local cosign key pair and a local RFC 3161 TSA, written to .keys/
#    (gitignored)
./scripts/setup-signing.sh

# 2. sign one event of the committed synthetic fixture (its normalization event).
#    The object goes in a package directory; the sidecars are written beside it.
mkdir -p /tmp/teste-pkg && cp -r testdata/teste-fixture /tmp/teste-pkg/object
go run ./cmd/provenant-attest -object /tmp/teste-pkg/object \
  -event aaaaaaaa-0000-4000-8000-000000000001 \
  -schema testdata/teste-fixture/preservation-event-v0.1.schema.json

# 3. or every event in the object, plus the signed disposition
go run ./cmd/provenant-attest -object /tmp/teste-pkg/object -all \
  -schema testdata/teste-fixture/preservation-event-v0.1.schema.json \
  -reports /tmp/teste-reports

For an object at <pkg>/object, the sidecars are written to <pkg>/provenant/attestations/<event-uuid>.{dsse.json,tsr} and the disposition to <pkg>/provenant/disposition.{dsse.json,tsr}: beside the object, never inside it, so the object stays valid under a stock OCFL validator (ADR-05). provenant-verify reads the whole package as --package <pkg>. -out names another directory for the attestations, and the disposition then goes to its parent. The OCFL object's own files are left untouched. Each run self-verifies what it wrote with cosign verify-blob-attestation and openssl ts -verify (-no-verify skips that).

Two defaults to know: -schema defaults to ../provenant/preservation-event-v0.1.schema.json, the umbrella repository checked out beside this one; -reports defaults to reports/ in the current directory, which in this repository holds the committed reports of the article's run, so pass -reports when experimenting.

Signing the AIP import event

provenant-import records the FACTS of an AIP -> OCFL crossing (its reports/import-event.json) but does not sign. provenant-attest signs that record as one more PreservationEvent (eventType ingestion), bound to the authoritative METS, via the same DSSE/cosign/TSA path:

# alongside the whole-object set (folds the import event into the disposition):
go run ./cmd/provenant-attest -object <pkg>/object -all \
  -import-event <import-event.json> ...

# or just the import event:
go run ./cmd/provenant-attest -object <pkg>/object -import-event <import-event.json>

The source AIP identity (its sha256) and the equivalence accounting are carried in the signed predicate's eventOutcomeDetail.extension, so the attestation is self-describing. The subject is the METS logical path (the SC-9 round-trip carrier), because the verifier's three-way join requires a head-state subject; the source AIP is recorded as a fact, not an in-toto material link.

Signing a setgrep recognition

setgrep recognises multipart preservation objects (e.g. an ESRI Shapefile) among an imported AIP's loose files; provenant-enrich records each recognition as a metadata/recognition/*.grex.json artifact in a new OCFL version. provenant-attest signs that recognition pass as one PreservationEvent (eventType format identification, agent setgrep), via the same DSSE/cosign/TSA path:

# alongside the whole-object set (folds the recognition into the disposition):
go run ./cmd/provenant-attest -object <pkg>/object -all \
  -recognition <pkg>/object/<head>/content/metadata/recognition ...

# or just the recognition event:
go run ./cmd/provenant-attest -object <pkg>/object -recognition <recognition-dir>

The subjects are the grex artifacts themselves, bound to their OCFL head-state logical paths by inventory digest (so the verifier's three-way join closes); the recognised objects, their completeness, and member roles are carried as facts in eventOutcomeDetail.extension. The event is non-transforming (no derivedFrom), and its eventDateTime is the head version's OCFL created time, so the signed bytes are deterministic. cosign's sha256 claim check binds when the inventory vouches a sha256 for each grex artifact; recording that fixity is provenant-enrich's job, and not yet done. Where it is absent, the recognition falls back to signature-only binding, exactly as the import event does (see the Notable decision below).

Layout

Path Role
internal/ocfl parse inventory.json; fold manifest + every fixity block into one per-file digest set; join by digest
internal/mets stream PREMIS-in-METS -> events, file objects, agents, derivation pairs
internal/predicate predicate/Statement types, the METS->predicate builder, and strict v0.1 schema validation
internal/sign shell to cosign attest-blob / verify-blob-attestation (offline; no Rekor)
internal/tsa shell to openssl ts against the local TSA
cmd/provenant-attest the pipeline orchestrator
testdata/ two small synthetic OCFL objects (their files say so in their own text) and the vendored predicate schema
reports/ the lossy-field log and exclusions report from the article's run

Notable decision

cosign --check-claims matches the in-toto subject digest on sha256 only. Subjects are bound to every inventory-vouched digest (sha512 primary + sha256 when the inventory carries it) so both the independent provenant-verify (sha512) and stock cosign (sha256) can bind. sha256 is not guaranteed by OCFL in general; see ADR-01 for the constraint and the Phase-2/3 mitigation.

Build & test

go build ./...
go test ./...

go test ./... runs on a bare clone against the committed synthetic fixtures; nothing needs to be staged. The signing and timestamping tests need cosign and openssl on PATH and skip without them. One acceptance test runs the independent verifier over a whole signed object; it needs a provenant-verify binary on PATH or named by PROVENANT_VERIFY_BIN, and skips without one. A test also keeps every text file in the repository plain ASCII.

CI. 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: build, vet, test, govulncheck and an SBOM. It runs on the maintainers' signed CI image, selected by the runner label go-ci; a host with no runner carrying that label will not pick the job up.
  • compliance.yml: REUSE lint and the DCO sign-off check.

AI assistance

This code was written with AI assistance (Anthropic's Claude), and that is disclosed: AI-assisted commits carry a Co-Authored-By: trailer. The policy mirrors the Linux kernel's. AI-assisted contributions are permitted and must be disclosed. An AI cannot provide the Signed-off-by: a human signs off every commit on main and thereby takes responsibility for the contribution's origin and licensing under the DCO. Nothing reaches main on an AI's authority alone (ADR-08).

Licensing & contributing

REUSE-compliant: code is Apache-2.0, docs are CC-BY-4.0 (ADR-07). Every file carries an SPDX header or is declared in REUSE.toml.

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.