Trust model¶
Status: pre-alpha. Milestone 1 (contract + fingerprint) is implemented.
Signing (attest.Sign), push, lookup and fetch (internal/registry),
verification (evidence.Verify, attest.Verifier) and the policy file
(policy.Load) are implemented; aval verify, aval run and aval push
use them. This document describes the model the code is built around; where
behavior is not implemented yet, it says so.
The predicate type, media types and the aval.trianalab.io/v1 apiVersion are
frozen; see artifact-format.md.
What a signature proves¶
An Aval attestation is a DSSE envelope around an in-toto Statement whose
predicate (evidence.Predicate) says: "test test.id, with inputs hashing
to execution.fingerprint, produced result on subject".
A valid signature proves:
- Who claims it. The signer identity (OIDC issuer + certificate subject for keyless, or a key) is bound to the claim.
- Integrity of the claim. The predicate has not been modified since it was signed.
A valid signature does not prove:
- that the test actually ran;
- that it ran honestly (unmodified test code, no skipped cases, no patched runner);
- that inputs not declared in
aval.yamlwere the same. The fingerprint only covers what the contract declares (see contract.md). Anything else is invisible to Aval.
So reuse is never "the signature is valid". It is "the signature is valid, the signer is someone the policy trusts at a sufficient level, and the fingerprint and test id match what we computed locally".
runner.identity, runner.issuer and runner.trustLevel inside the
predicate are what the signer claims. The authoritative identity is the
verified signer, and the trust level is the one the policy grants to that
signer, not the one written in the predicate. In code: attest.Verifier
verifies the Sigstore bundle and returns the evidence.Signer it binds (the
OIDC issuer and subject of the Fulcio certificate, or the key's id), and
evidence.Verify looks that signer up in the policy's Identities with
evidence.MatchSigner. A key-based signer only matches identities that list
its public key; a keyless one only matches issuer and subject entries.
Signing¶
attest.Sign (sigstore-go) signs the in-toto statement as a DSSE envelope
in one of two modes:
- Keyless (CI): an ephemeral key, a short-lived Fulcio certificate for
the OIDC token the caller passes in, and a Rekor entry (always recorded,
since the certificate expires in minutes and Rekor's timestamp is what
makes it verifiable later). Signing always uses the Sigstore public-good
instance (
https://fulcio.sigstore.dev,https://rekor.sigstore.dev); the endpoints cannot be configured (TODO(milestone-5): signing against a private Sigstore deployment; verification already accepts its pinnedtrustedRoot). Obtaining the OIDC token is the caller's job, so no CI vendor or issuer is assumed. - Key-based (local): an unencrypted ECDSA P-256 PEM key (PKCS#8 or SEC1),
for example from
openssl ecparam -name prime256v1 -genkey -noout -out aval.key. Cosign keys are encrypted and not supported. No Rekor entry. The bundle's key hint is the key'sevidence.PublicKeyID. Expect policies to grant such signatures at most level 1.
Besides the envelope, Sign returns the Sigstore bundle (v0.3 JSON) with the
verification material (certificate or key hint, Rekor entry). registry.Push
requires it and stores it next to the envelope as the second layer of the
execution manifest (see artifact-format.md).
Trust levels¶
evidence.TrustLevel, higher is stronger:
| Level | Name | Constant | Who | Good for |
|---|---|---|---|---|
| 0 | unknown | TrustUnknown |
level could not be established | never accepted |
| 1 | developer | TrustDeveloper |
a developer's local signature | personal acceleration only: skip a test you already ran on your machine. Not full trust. |
| 2 | runner | TrustRunner |
a CI runner with a verifiable workload identity (e.g. GitHub Actions OIDC) | reuse in CI. CI should require at least this level. |
| 3 | isolated | TrustIsolated |
an isolated execution with a verifiable attestation of the environment itself | reuse where the runner host is not trusted either |
Level 1 exists because a local signature is still useful to its author.
Expect many security policies to refuse it outright; that is the intended
use of MinTrustLevel.
Policy¶
The trust policy decides which signers are accepted and at what level. It
is policy.Policy (internal/policy), converted to
evidence.VerifyOptions for evidence.Verify:
| Field | Meaning |
|---|---|
Identities |
accepted signers. Each evidence.Identity is either an OIDC Issuer plus a SubjectRegexp matched against the whole certificate subject (SAN), or a PublicKey, and the TrustLevel that signer is granted. Empty means nothing is accepted. |
MinTrustLevel |
minimum granted level required for reuse. Level 0 (unknown) and levels above 3 are never accepted, whatever this says. |
AllowedCacheability |
cacheability classes that may be reused besides hermetic and controlled-environment. Only external-dependencies has an effect; non-deterministic is never reusable. |
TrustedRoot |
Sigstore trusted root for keyless signatures (policy file only). |
ExpectedFingerprint, ExpectedTestID |
set by Aval from the local contract, not by the policy file. Must equal the predicate's execution.fingerprint and test.id. Required. |
evidence.Verify checks, in order: the signature (through
VerifyOptions.Signature), that the signer matches an accepted identity,
that the granted level is at least MinTrustLevel, that the payload is an
Aval in-toto statement whose subject equals the predicate's, the predicate
structure, the expected fingerprint and test id, and the cacheability. Any
failure is a *VerificationError (ErrVerification). The verified
Signer is the policy entry that matched, plus the subject or keyId the
signature actually carried, so the verify report shows who really signed.
The CLI (aval verify, aval run) checks the minimum level last instead:
it calls evidence.Verify with MinTrustLevel developer, then checks the
signed cacheability against the contract, then the policy's
minTrustLevel. An attestation that passes every other check but whose
signer the policy lists below minTrustLevel is routine non-reuse, like a
developer result under a CI policy: outcome miss (exit 4), listed in
rejected with outcome miss, never reused and never a security event. Any
other failure, including a false claim from such a signer, is
verification-failed (exit 5). Library callers of evidence.Verify still
get ErrVerification for a level below MinTrustLevel.
attest.Verifier checks signatures with sigstore-go against the Sigstore
bundle stored next to the envelope. A keyless signature must chain to a
Fulcio CA in the trusted root, carry an embedded SCT from a trusted CT log,
have a Rekor entry, and have a trusted timestamp (Rekor or a TSA) inside the
certificate's validity. A key-based signature must verify with one of the
policy's public keys; no Rekor entry is required. The envelope must be
byte-for-byte the one the bundle signs. An attestation without a bundle
never verifies.
Without --policy nothing is trusted. Aval does not even query the
registry: the outcome is miss (exit 4) with the reason "no trust policy
configured", and the test runs. This avoids reporting every attestation it
finds as a verification failure just because no policy exists. Once a policy
is configured, an attestation from a signer it does not list is a
verification failure (exit 5); one from a signer it lists below
minTrustLevel is a miss (exit 4). A policy file that cannot be loaded is
outcome error (exit 1): the test runs and nothing is looked up. The
trusted root is loaded only when a candidate's Sigstore bundle carries a
certificate (a key-based bundle never needs it), at most once per command;
if it cannot be loaded, that candidate is outcome error (exit 1, the test
runs), never a verification failure.
Policy file¶
apiVersion: aval.trianalab.io/v1
kind: Policy
minTrustLevel: runner # developer | runner | isolated
# trustedRoot: sigstore/trusted_root.json # keyless only; default: Sigstore public-good, fetched with TUF
identities:
- issuer: https://token.actions.githubusercontent.com
subjectRegexp: https://github\.com/acme/orders/\.github/workflows/ci\.yaml@refs/heads/main
trustLevel: runner
- publicKey: keys/alice.pub # PEM public key file
trustLevel: developer
allowedCacheability: [external-dependencies]
policy.Load rejects, rather than ignores: unknown fields, more than one
YAML document, another apiVersion or kind, a missing or unknown
minTrustLevel or trustLevel (levels are written by name), no identities,
an identity with both publicKey and issuer/subjectRegexp or with
neither, a subjectRegexp that does not compile alone and anchored, a
publicKey file that is not a PEM public key, and non-deterministic or an
unknown class in allowedCacheability. Relative trustedRoot and
publicKey paths are resolved against the policy file's directory.
Keyless verification needs the network and a writable HOME¶
A policy with an issuer identity and no trustedRoot uses the Sigstore
public-good trusted root. sigstore-go fetches it with TUF from
tuf-repo-cdn.sigstore.dev and caches it under $HOME/.sigstore. Aval loads
it only when a candidate's Sigstore bundle carries a certificate, at most once
per command. A miss or a key-based attestation never fetches it. Without
network access, or with a HOME that cannot be written, the trusted root does
not load: that candidate is outcome error (exit 1, never
verification-failed), and aval run runs the test. Reuse is lost, never
faked.
To avoid the dependency, pin the trusted root in the policy:
- Get
trusted_root.jsononce, on a machine with network access. After one keylessaval verifythere, sigstore-go has it at$HOME/.sigstore/root/tuf-repo-cdn.sigstore.dev/targets/trusted_root.json. - Commit it next to the policy and set
trustedRoot: trusted_root.json(relative to the policy file). - Refresh it when Sigstore rotates keys: a stale root fails to verify new
certificates (outcome
verification-failed), so watch for that after a Sigstore announcement.
Policies with only publicKey identities never fetch anything.
Fail closed¶
If Aval cannot prove that a previous execution is equivalent and trusted, the test runs.
- Fingerprint errors (empty source scope, missing or mismatched dependency, symlink in scope, unreadable file): input equivalence cannot be proven, so there is nothing to look up. Run the test.
- Registry errors (unreachable, auth failure): outcome
error, exit 1. For execution purposes this is a miss: run the test. A malformed attestation manifest, or a fingerprint tag that does not point at the fingerprint anchor, is a verification failure (exit 5). - Consume by digest.
registry.Lookupresolves the tagsha256-<fingerprint hex>, checks that it points at the fingerprint's deterministic anchor manifest (a moved tag is a verification failure), and returns the anchor's referrers as candidate manifest digests; everything after that (fetch, verify, reportreference) usesrepo@sha256:.... The tag is only an index for lookup. It is mutable and is never trusted on its own. - No silent replacement. Pushes only add referrers to the anchor; a later
push for the same fingerprint cannot replace an earlier attestation. Every
candidate is verified and the policy picks the verified one with the
highest trust level. A candidate that fails verification never decides the
outcome while another verifies, and is listed in
rejectedin the verify report (and stderr gets oneaval: SECURITY: ...line), so the security event stays visible. A candidate that cannot be fetched or checked blocks reuse (the test runs), because it might have outranked the verified one. - Unknown or missing fields in
aval.yamlare rejected (exit 2) rather than ignored.
aval run --no-reuse always runs and never looks anything up.
What aval run does with each outcome¶
aval run makes the same decision as aval verify and prints it as the
verify text report (--report <file> also writes the JSON report). Only
verified reuses; everything else runs runner.command in the contract's
directory:
| Lookup outcome | aval run |
Exit code |
|---|---|---|
verified, result passed |
reuse, do not run | 0 |
verified, result failed |
reuse the failure, do not run | 3 |
miss (also: no --registry, no --policy, --no-reuse, non-deterministic, external-dependencies not in AllowedCacheability, only signers below minTrustLevel) |
run | test result: 0 or 3 |
error (registry, policy or fingerprint error) |
run | test result: 0 or 3 |
verification-failed |
run, print aval: SECURITY: ... on stderr |
5, whatever the test result |
A fingerprint error leaves nothing to look up or record: the test runs, the
report has outcome error with the reason, and --push fails (exit 1).
Otherwise the run is recorded as an unsigned predicate in --result
(default .aval/result.json) and, with --push, signed and published.
aval push publishes a recorded result later, only if its test id,
cacheability and fingerprint still match the contract.
A --report file that cannot be written does not skip the test: aval run
then never reuses, runs the test, and still exits 5 on a verification
failure.
$SIGSTORE_ID_TOKEN is read once and removed from the environment before
runner.command starts: the code under test cannot use it to sign results as
the workload identity.
Reasons can carry text from an untrusted attestation, so the text output
and stderr escape newlines, control and other non-printable characters and
invalid UTF-8 with Go escapes (\n, \x1b, \u2028); quotes and
backslashes stay as they are. Such text never starts a line of its own (a CI
log command, a terminal escape). The JSON report keeps the reason as is.
Each command prints the reason once, in the report: stderr does not repeat
it. On a verification failure stderr gets a single aval: SECURITY: ...
line that counts the attestations that failed and links here; a signer the
policy does not list gets the reason signer key id "<id>" is not in the trust
policy (trust policy <file>) (for a certificate, signer "<subject>"
(issuer "<issuer>") is not accepted by the policy (trust policy <file>);
when the policy lists only keys the certificate is not verified, so the
reason is certificate claiming signer "<subject>" (issuer "<issuer>") is not
accepted by the policy: it lists no keyless identities (trust policy <file>)).
For a verified result the text report adds signer: (key id, or subject and issuer, with the trust level
name) and reference: lines. Errors joined from several causes print one aval: line per cause (an invalid
aval.yaml); in a report (an invalid policy) the causes share the reason: line, separated by semicolons.
--record publishes a run record for statistics after the decision, for
every outcome (see artifact-format.md).
Records are unsigned, never read by aval run or aval verify, and never
affect reuse or the exit code: a record that cannot be published is a
warning.
--shadow verifies and runs the test anyway. If a verified result disagrees
with the real run, that is a false hit: aval run prints aval: FALSE HIT:
... on stderr and exits 1. If they agree, it says reuse would have been
correct.
Defense in depth for cacheability (invariant 7): a non-deterministic
contract is a miss without any lookup, in aval run and aval verify, and
so is an external-dependencies contract that the policy does not list in
AllowedCacheability (routine non-reuse, not a security event). Also,
a candidate whose signed test.cacheability differs from the local
contract's is a verification failure, even if the policy would allow its
class.
Miss vs verification failure¶
These are different outcomes, in code and in exit codes:
| Situation | aval verify outcome |
Exit code | Go error |
|---|---|---|---|
| valid attestation, policy satisfied | verified |
0 | nil |
| no attestation for this fingerprint | miss |
4 | wraps evidence.ErrNotFound |
only authentic attestations whose signer the policy lists below minTrustLevel |
miss |
4 | CLI only (see Policy) |
| attestation exists but fails signature or policy | verification-failed |
5 | wraps evidence.ErrVerification (*evidence.VerificationError carries the reason) |
| Aval could not decide (registry down, I/O) | error |
1 | other |
A miss is routine: inputs changed, nobody ran this yet. Run the test.
A verification failure is a security event. Someone published a result
under this fingerprint that does not verify, or was signed by an identity
the policy does not trust, or claims a different test id or fingerprint
than the tag it sits under. Possible causes range from a misconfigured
policy to a compromised publisher. The caller must run the test and
surface the failure: fail the job, page, or at least emit a visible alert.
Never collapse it into a silent re-run; doing so hides exactly the case the
trust model exists to catch. That is why it has its own exit code (5) and
its own outcome in aval verify --output json.
Cacheability classes¶
Declared per test in aval.yaml (cacheability), copied into the
predicate as test.cacheability. The contract (including this field) is
hashed into the fingerprint, so a matching fingerprint implies the same
declared class.
| Class | Meaning | Reusable by default |
|---|---|---|
hermetic |
depends only on declared files and toolchain | yes |
controlled-environment |
depends on declared services/images pinned in scope (e.g. compose file with digests) | yes |
external-dependencies |
talks to things outside the scope (SaaS APIs, shared databases) | no, only if the policy lists it in AllowedCacheability |
non-deterministic |
results vary across runs with identical inputs | never |
Threats¶
| Threat | Mitigation |
|---|---|
| Compromised developer key | Policy grants developer identities level 1 at most; CI sets MinTrustLevel >= 2, so a stolen developer key cannot satisfy CI. It can still poison that developer's own reuse. Prefer keyless (short-lived Fulcio certs, Rekor log) over long-lived keys. |
Tag swapping (sha256-<fp> repointed to another manifest) |
Tag is lookup only and must resolve to the fingerprint's deterministic anchor manifest; any other target is verification-failed. Candidates are the anchor's referrers, fetched and verified by digest. |
| Replay of a passing result for a different fingerprint or test id | Verify requires execution.fingerprint == ExpectedFingerprint and test.id == ExpectedTestID, both computed locally. The signed predicate cannot be re-labeled without breaking the signature. Mismatch is verification-failed. |
| Undeclared input changed (file outside scope, unlisted env var, network service) | Out of scope for detection in v0: the fingerprint only covers declared inputs. Mitigated by contract review, external-dependencies not being reusable by default and non-deterministic never being reusable, and the false-hit suite (roadmap item 4). Observed-input tracing is future work. |
| Registry compromise (attacker can write arbitrary manifests and tags) | Cannot forge signatures, so it cannot produce reuse on its own; injected attestations show up as verification-failed, or, when another candidate verifies, as a rejected entry with outcome verification-failed in the verify report (and an aval: SECURITY: ... line on stderr). It can delete attestations (forcing misses, i.e. re-runs) or deny service (error, re-run). Availability is out of scope. |
| Forged or tampered run records (anyone with push access) | Records are statistics, never evidence: unsigned, hung off a per-test stats anchor, and never fetched by aval run or aval verify, so they cannot cause reuse or count toward the candidate cap. A record crafted with a fingerprint anchor as its subject is filtered out by artifact type. They can skew statistics; treat them as informational. |
| Dishonest but trusted signer (ran a modified test and signed "passed") | Out of scope for Aval. A signature proves who claims it, not that the test ran honestly. Limit which identities get which level; level 3 (isolated) exists for this. |