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 §6Unknown 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 stepsOnly 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>\nRules 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/attest5. 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
- Decode the base64 to DER and parse the CMS
SignedData. - Extract the encapsulated
TSTInfo; readgenTimeandmessageImprint. - The imprint must equal
SHA-256(raw signature bytes). If not, the token is about something else and means nothing here. - The
message-digestsigned attribute must equal the digest ofTSTInfo DERusing the declared CMS digest algorithm, SHA-256 or SHA-384. - Verify the authority's signature over the signed attributes, re-encoded
with the
SET OFtag0x31in 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. - 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:
- Take the receipt.
- Set
signatureandsigner_public_keyto null. - Sort
stepsbyname. 2b. Settimestamp_tokento null. - Sort
causal_events. - 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.
signatureis the 64-byte Ed25519 signature, lowercase hex (128 chars).signer_public_keyis 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.
--timestampis 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.