BenefitsCLI docsRelease 0.4.0Get started
CLI documentation

Receipt format

A receipt is the evidence attest produces. This document specifies it completely enough to write an independent verifier without reading the Rust.

That is the point. "Anyone can check it" is not a property of our implementation being available — it is a property of the format being written down.

Current version: schema_version: 3.

Sections marked planned describe fields the format reserves but this release does not yet write. They are here so an independent implementer knows what is coming, not so anyone can claim support for them.


Version 0.4.0 keeps schema 3 and verifies earlier receipts, including signatures. Optional isolation and provenance.execution fields are omitted when absent so that older receipts retain their original signing bytes. Older verifiers may reject newly added fields; upgrade them before delivering new evidence.

1. Where receipts live

.attest/receipts/receipt_<UTC timestamp>_<pipeline>_<suffix>.yaml, YAML, UTF-8. The filename carries a random suffix so two receipts produced in the same millisecond cannot collide; nothing in the format depends on it.

2. Document

schema_version: 3
pipeline_hash: "9f2c…"            # 64 lowercase hex
steps: [...]                      # see §3
timestamp: "2026-09-16T10:36:36.554327Z"
total_duration_secs: 4
signature: "a71f…"                # 128 lowercase hex, or null
signer_public_key: "333d…"        # 64 lowercase hex, or null
attest_version: "0.4.0"
causal_events: ["…", "…"]         # event ids
causal_chain_hash: "4c8b…"        # or null
reproducibility: {...}            # optional, see §5
provenance: {...}                 # optional, see §6
timestamp_token: "MIIG…"          # optional, base64 DER, see §7
isolation: {...}                  # optional, see §6

Unknown fields are rejected. A receipt is a security document; a field the verifier does not understand is a field it cannot reason about. This is the opposite of the plugin wire protocol, which must tolerate unknown fields to evolve.

Version handling. A verifier reads schema_version from the raw document before strict parsing. A value higher than it supports must produce an explicit "upgrade" message, never an opaque unknown-field error. A receipt with no schema_version is version 1 and remains valid: the field is omitted rather than written as null precisely so that pre-versioning receipts, and their signatures, stay byte-identical.

3. Steps

steps:
  - name: "build"
    input_hash: "1a2b…"           # 64 lowercase hex, see §4
    output_hash: "3c4d…"
    duration_secs: 3
    exit_code: 0
    cache_hit: false
    stdout: "…"
    stderr: "…"
    capsule_hash: "5e6f…"         # optional; present for capsule steps

Only steps that ran appear. A step skipped because a dependency failed is absent: a receipt entry must carry real hashes, and a step that never ran cannot produce them. A failed step is present, with its non-zero exit_code.

4. Hashes: attest-manifest/v1

input_hash and output_hash are blake3(manifest_bytes) where the manifest is a canonical text document:

attest-manifest/v1\n
run:<blake3-hex of the step's run command, UTF-8 bytes>\n   (input manifests only)
f:<blake3-hex of file content> <normalized relative path>\n
l:<blake3-hex of symlink target string> <normalized relative path>\n

Rules a conforming implementation must follow:

  • Lines after the header are sorted by byte-wise lexicographic order of the normalized path, not by locale and not by insertion order.
  • Paths are relative to the workspace root, normalized, /-separated.
  • Symlinks are never followed. A symlink is recorded by hashing its target string (l: line). A symlink whose target escapes the workspace root is an error, not a skip.
  • A declared path that does not exist is a hard error. Never a silent skip: silently omitting a missing input would let a receipt claim coverage it does not have.
  • Directories contribute no line of their own, so an empty directory is invisible to the manifest.
  • The run: line is domain-separated from file lines by its prefix, so a file whose content equals a command cannot be confused with it.

attest hash exposes this directly, which is the simplest way to check an independent implementation against ours:

$ attest hash --run "cargo build" --input src/ --input Cargo.toml
$ attest hash --output target/release/attest

5. Reproducibility

reproducibility:
  verified: true
  runs: 2
  method: "double-build"

Written only by attest run --check-reproducibility. Absent means not checked — which is not the same as not reproducible, and a verifier must not report it as either.

6. Provenance

Signed observations made by the runner about itself: invocation_id, started_at, pipeline_name, source (repository URL, commit, branch, tracked_dirty), runner_os, runner_arch, ci (provider, run id, job id), per-step command_hash with declared inputs and outputs, and produced artifacts with their digests.

These are assertions, not independent evidence. A runner that lies about its own commit produces a receipt that is internally consistent and wrong. Provenance is useful for correlation and audit; it is not a trust anchor.

Execution descriptor (0.3.1)

execution is an optional final field of provenance, after artifacts. It is omitted when absent, never serialized as null. A native pipeline writes {"mode":"native"}. A wrapped command writes:

{"mode":"wrapped","command":["npm","run","build"]}

Unknown descriptor fields and modes are rejected. The wrapped command is an array of strings preserving argument boundaries. Its canonical bytes are compact UTF-8 JSON with no whitespace between elements, as emitted by serde_json. For the single wrapped step, command_hash is BLAKE3 of that array. pipeline_hash is BLAKE3 of attest-wrap/v1\n followed by the same array; the input manifest's run: line uses command_hash.

This signed descriptor allows recomputation from declared workspace files without attest.yaml and without executing the command. Absence on an older receipt establishes neither execution mode. Without a matching pipeline or wrapped descriptor, recomputation is explicitly skipped and cannot satisfy --require recompute. See the wrapped recomputation contract.

Isolation controls (0.2)

The optional isolation field follows timestamp_token in field order and is included in the signing bytes. It contains mode, platform, a controls list and, when needed, capsule_steps. Each control has a name, status (enforced, not-enforced or not-available) and detail. Controls cover network, filesystem, capabilities, environment, memory, open files, CPU, timeout and declared inputs. An absent block establishes nothing about isolation. --verify applies process limits, without network or filesystem isolation. The report describes controls observed by the runner; it does not prove that the runner was uncompromised.

7. Timestamp token

An RFC 3161 TimeStampToken, base64 of its DER, written by attest run --sign --timestamp, attest sign --timestamp or attest timestamp. Absent when no token was attached. Requests and redirects require HTTPS; the default service is https://timestamp.sectigo.com/rfc3161.

The token covers the signature, not the receipt body. Signing binds the body; timestamping binds the signature. Together they place a specific signature by a specific key at a time the signer did not choose. Timestamping the body instead would prove the content existed — true, but silent on when it was signed, which is the question that matters.

The imprint is SHA-256 of the raw signature bytes, that is the 64 bytes obtained by hex-decoding signature.

The field is outside the signed bytes (§8). It has to be: the token is issued after the signature exists, over that signature. Including it would be circular. Its integrity comes from the authority's signature, not ours.

Verifying one

  1. Decode the base64 to DER and parse the CMS SignedData.
  2. Extract the encapsulated TSTInfo; read genTime and messageImprint.
  3. The imprint must equal SHA-256(raw signature bytes). If not, the token is about something else and means nothing here.
  4. The message-digest signed attribute must equal the digest of TSTInfo DER using the declared CMS digest algorithm, SHA-256 or SHA-384.
  5. Verify the authority's signature over the signed attributes, re-encoded with the SET OF tag 0x31 in place of the wire's implicit [0] (RFC 5652 §5.4). The reference implementation supports RSA PKCS#1 v1.5 signatures with SHA-256 or SHA-384; unsupported or inconsistent algorithm declarations are refused.
  6. The signing certificate must be issued by a pinned authority, and must have been valid at genTime.

The reference implementation pins the issuing CA, not the responder, and verifies exactly one signature between them. It builds no X.509 path: no discovery, no name constraints, no CRL or OCSP. Responder certificates rotate, often yearly; pinning one would stop verification of receipts issued after the rotation.

Plugins (planned)

A plugins list is reserved for a later schema version: one record per plugin consulted, folded into the signing bytes. Two invariants are fixed now because verifiers will depend on them — a plugin can never change a value in §3, and a verifier must never require a plugin to reach its verdict. See the plugin protocol.

8. Signature

Ed25519 over the canonical signing bytes, which are built as follows:

  1. Take the receipt.
  2. Set signature and signer_public_key to null.
  3. Sort steps by name. 2b. Set timestamp_token to null.
  4. Sort causal_events.
  5. Serialize as JSON — not YAML — in struct field order, exactly as listed in §2.

The three cleared fields are the ones that only exist once signing has happened. Clearing them is what lets a receipt be signed, then timestamped, and still verify.

The result is the byte string that is signed.

  • signature is the 64-byte Ed25519 signature, lowercase hex (128 chars).
  • signer_public_key is the raw 32-byte Ed25519 public key, lowercase hex (64 chars).
  • The key id used throughout the CLI and the trust store is hex(blake3(raw_public_key)[..16]) — the first 16 bytes of the digest, 32 hex characters.

Note the asymmetry: receipts are stored as YAML and signed as JSON. The signing form is canonical and stable; the storage form is for humans.

9. Trust store

.attest/trust/ is committed to the repository: <key-id>.pub holds a PEM-encoded public key, and trust.toml records the policy.

version = 1
 
[[key]]
id = "0b948c80672304dda0bb674dcc963b71"    # hex(blake3(pubkey)[..16])
name = "release"
status = "trusted"          # or "revoked"
# revoked_at = "2026-09-16T10:00:00Z"

Revocation is time-aware. A receipt signed before revoked_at still passes, with a warning; a receipt signed after it fails. Revoking a key does not retroactively invalidate work done while it was trusted.

Which clock decides. For a revoked key, the verifier uses the time from an independently verified timestamp token. Without that token it refuses the receipt by default: a stolen-key holder could backdate timestamp. The explicit --trust-receipt-timestamp option permits falling back to the runner’s signed time, with a warning. This weakens verification.

Pinned timestamp authorities live in .attest/trust/tsa/, added with attest keys trust-tsa <certificate> --name <name>. Several may be pinned, and old ones should stay: they are what keeps old receipts verifiable after an authority rotates.

Keys are never deleted, only marked revoked. Private keys live in .attest/keys/ by default and are git-ignored; ATTEST_KEYS_DIR can move them outside the workspace; attest keys export refuses to emit anything that is not a public key.

Signed trust bundles (0.2)

attest trust bundle transfers keys, dated revocations, issuer, validity window and sequence number in a signed bundle. The first attest trust import requires an issuer obtained through a separate trusted channel. attest trust refresh rejects older sequences, tampered bundles and revoked issuers. Freshness is assessed against the local clock and last imported bundle, without network access. See the CLI reference for commands and requirements.

10. Verification

Five checks, in order, each independently reportable:

Check What it establishes
schema Well-formed, and of a version this verifier understands
consistency Internal references hold together
signature Signed by a trusted key, before that key was revoked
timestamp A third party attested when the signature existed
recompute Declared inputs and outputs still hash to what the receipt records

recompute is opt-in (--recompute) because it needs the workspace; the first three are pure functions of the receipt and the trust store.

An absent or unexecuted check is skipped. Without a policy, a verdict passes when no check fails; it does not mean every check ran. --require signature,timestamp,recompute requires each property to be established. linkage, trust and success are also available. Build success is separate from valid evidence. The JSON policy, execution and trust_material fields make those distinctions explicit.

Exit codes: 0 every receipt passes, 1 at least one verification failure, 2 operational error (unreadable file, bad flags). A verifier that conflates 1 and 2 makes a CI pipeline unable to tell "the evidence is bad" from "I could not read the file".

11. What a valid receipt does not establish

Stated here so an independent implementer does not overclaim on our behalf:

  • Without a timestamp token, the time is the runner's clock, written and signed by the runner. --timestamp is what replaces that with a third party's word.
  • External publication is explicit. The receipt alone contains a local causal record. In 0.4, a separately retained publication record can bind its root to an external log through a randomized commitment. Verify that proof with independently provisioned log trust; see the publication guide.
  • The signer is a key, not a person. There is no binding between an Ed25519 key and a legal identity.

A receipt carries signed claims and file hashes. External inclusion and checkpoint comparison are separate evidence checks; neither establishes that the build was correctly executed.