Skip to content

Evidence security and tooling

This page covers the pacto evidence command group — how a remote environment mints keys, signs a Pacto EvidenceSet, reports it and how a platform verifies and ingests it — plus the security invariants the protocol upholds. For the wire format itself, see the external evidence protocol.

The commands split cleanly by side of the trust boundary:

Command Side Purpose
pacto evidence keygen producer Mint an Ed25519 signing keypair.
pacto evidence sign producer Wrap an EvidenceSet in a signed envelope.
pacto evidence send producer Report a signed envelope outbound to an ingestion endpoint.
pacto evidence verify either Verify a signed envelope against a trust store.
pacto evidence serve platform Run the ingestion endpoint that accepts, verifies and evaluates envelopes.

Minting a keypair

pacto evidence keygen --out ./keys --producer edge-eu-west --key-id edge-eu-west-2026

This writes two files into --out:

  • <keyId>.key — the base64 32-byte Ed25519 seed, written 0600 (owner read and write only). This is secret material. Keep it in the environment that produces evidence; never commit it, never ship it to the platform.
  • <producer>__<keyId>.pub — the base64 public key, written 0644. The filename binds the key to that producer in the trust store, so the platform authorizes it only for evidence signed as that producer. This is the file you hand to the platform operator; you do not need to know or apply the filename convention by hand — --producer writes it for you.

With no --producer, the key is bound to a producer named after the key id and the file is a bare <keyId>.pub (the single-producer default). With no --key-id, the key id defaults to a short fingerprint of the public key. Sign with the same --producer and --key-id you minted the key with. sign will not stop you naming a different producer, but the platform will: verification and ingestion reject the envelope as producer id is not authorized for this key (401 unauthorized_producer).


Signing an EvidenceSet

pacto evidence sign evidence.json \
  --key ./keys/edge-eu-west-2026.key \
  --key-id edge-eu-west-2026 \
  --producer edge-eu-west \
  --sequence 1 \
  --ttl 24h > envelope.json

sign reads evidence.json — an EvidenceSet in its native shape (top-level keys Subject, ContractRef, Source, ObservedAt, Observations; unknown keys are rejected) — validates it, wraps it in an envelope and prints the signed envelope JSON. Key flags:

  • --key (required) the private-key file, --key-id the trust-store key id it maps to, --producer the environment id.
  • --sequence the producer-scoped monotonic counter (default 0). Every report must carry a strictly greater value than the producer's last accepted one, or ingestion rejects it as a replay (409) — re-signing the same evidence under a fresh envelope id does not help. A producer that reports more than once has to persist this counter and increment it across runs.
  • --producer-version an optional producer or collector version, carried on the wire as producer.version.
  • --ttl the validity window (default 24h; 0 disables expiry).
  • --id and --issued-at (RFC3339) pin those fields for a fully deterministic envelope; otherwise issued-at defaults to now and id to a hash over the protocol version, --producer, --sequence and the evidence.

The signed envelope is a wire artifact, so sign always emits exact JSON regardless of --output-format.


Reporting an envelope

pacto evidence send envelope.json --url https://ingest.example.com

send POSTs the signed envelope to POST /api/evidence/v1/envelopes at the target host. This is the only outbound call a producer makes. The host verifies and evaluates the envelope and returns 202 Accepted on success or a status that names the failure category (see the protocol status codes).


Verifying an envelope

pacto evidence verify envelope.json --trust ./trust

verify decodes an envelope and checks its signature, freshness, producer authorization and trust against --trust, which is either a single public-key file or a directory of <producerId>__<keyId>.pub files (a bare <keyId>.pub binds the producer to the key id). Each key is authorized for exactly one producer, so a trusted key cannot sign as another. It exits non-zero when verification fails, so it drops straight into CI. The output reports the envelope id, producer and key id on success, or the sanitized failure reason on failure.

Structured trust configuration

The bare-.pub mode binds a key only to a producer; it cannot express the per-key subject and contract-repository allowlists the verification and ingestion layers already enforce. To configure those, point --trust at a versioned YAML trust config instead (a file ending in .yaml/.yml):

apiVersion: pacto.dev/evidence-trust/v1
keys:
  - keyId: edge-eu-west-2026
    producerId: edge-eu-west
    publicKeyFile: edge-eu-west__edge-eu-west-2026.pub   # a bare filename in this config's directory
    allowedSubjects:                  # path.Match globs; empty = any subject
      - payments-*
    allowedContractRepos:             # bare registry/repo prefixes; empty = any repo
      - registry.example.com/acme/contracts

Loading validates the schema version, the identifier grammar, duplicate key ids, contradictory producer bindings for one key file, missing/traversing key files and malformed subject/repo patterns. Because publicKeyFile resolves relative to the config's directory, a Kubernetes Secret mounted as a directory containing the config plus its .pub files can be pointed at directly. The bare-.pub mode remains supported but stays deliberately limited to producer binding — it does not enforce scopes it cannot express.


Running the ingestion endpoint

pacto evidence serve \
  --trust ./trust \
  --subject oci://registry.example.com/acme/payments-api@sha256:1a2b…

serve starts the ingestion host: it loads the trust store from --trust, resolves every --subject (repeatable, at least one required) in the contract registry, then evaluates each accepted envelope against the contract its ContractRef resolves to and publishes the result to that registry as an OCI 1.1 referrer of the subject. It holds nothing locally — there is no store directory, bucket or data volume. It listens on 127.0.0.1:<--port> (default 8686) or on --listen-address host:port, advertises the producer ids configured with --producer at GET /api/evidence/v1/producers and reports readiness at GET /api/evidence/v1/ready (503 until every subject resolves and answers native Referrers discovery). Registry access uses Pacto's normal OCI credential resolution — there is no evidence-specific login. TLS termination is the host's responsibility — run serve behind a TLS-terminating proxy or gateway. Signature verification is always on and cannot be disabled.

A registry on localhost, a loopback address or a private IP range is reached over plain HTTP automatically. Any other registry served over plain HTTP — an in-cluster registry Service name, for example — must be named in the PACTO_INSECURE_REGISTRIES environment variable, a comma-separated list of host:port values. It is scoped to exactly the hosts you list and never downgrades an HTTPS registry. Without it, subject resolution fails and serve never becomes ready.

For registry requirements, permissions, retention and failure semantics, see evidence in the registry; for how the server is deployed, see deployment.


Security invariants

The protocol and its tooling hold a small set of non-negotiable invariants.

  • No secret material in logs or errors. Verification failures return typed sentinel errors whose messages never contain private keys, public keys or raw signature bytes. The ingestion API echoes only a failure category, never the offending material.
  • Sanitized, categorised failures. Every accept outcome maps to a stable HTTP status — 400 for a body that could not be decoded, 401 for signature, trust or freshness failures, 409 for replay, 422 for an unevaluable payload or a rejected contract reference — with a generic message. A caller learns what class of thing went wrong, never internal detail it could exploit.
  • Bounded payloads. The ingestion endpoint reads the request body through an io.LimitReader one byte past the 1 MiB cap, so an oversized payload is refused before it is parsed; decoding then rejects any envelope carrying more than 10,000 observations. Neither a hostile nor a runaway producer can exhaust memory at the boundary.
  • Mandatory verification. An unsigned envelope, an unknown key, an unsupported algorithm or a bad signature is always rejected. Transport security (TLS) is the host's job and is additive; it never substitutes for signature verification.
  • Replay protection. A duplicate id or a non-increasing per-producer sequence is rejected, so re-sent or reordered reports never regress a target to older state. It is enforced inside the serialized commit, over a history re-enumerated from the registry every time, so it survives process and pod restarts and applies globally across subjects — a producer's sequence is the producer's, not the contract's (see evidence in the registry).
  • Reads fail honest, writes fail closed. A store that could not be read completely is reported as partial; a store that could not be read at all is refused outright with 503 registry_unavailable rather than served as an authoritative empty result. Ingestion refuses while the accepted history cannot be fully reconstructed — a replay check over a partial history is not a replay check.
  • Single active writer. Exactly one, enforced operationally (one replica plus the Recreate rollout strategy) rather than with a distributed lock, because the registry offers no compare-and-set two writers could agree on.
  • Registry write access is evidence write access. The producer signature is verified once, at ingestion; the read path does not re-verify it. Anyone who can push to a configured contract repository can add a record Pacto serves, so scope that credential as tightly as the right to publish a contract.
  • Evidence is never deleted by Pacto. It is the audit trail, and Pacto only ever pushes. Because the artifacts are deliberately untagged, a registry garbage-collection policy that prunes untagged manifests will remove them — exclude referrers of contract manifests from any such policy.
  • Trust store is a read-only mount. In the operator-managed deployment the trust store is a Secret of <producerId>__<keyId>.pub keys mounted read-only (evidence.trust.existingSecret), each key bound to exactly one producer so a trusted key cannot sign as another. Distributing it stays an out-of-band operator responsibility; the server never fetches keys.
  • Private keys are 0600. keygen writes seeds owner-only. Treat the .key file as a secret; only the .pub crosses the trust boundary to the platform.

See also